Compare commits

..

82 Commits

Author SHA1 Message Date
renovate[bot]
e08014c656 chore(deps): update dependency @edx/frontend-component-footer to v14.9.3 (#1288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 05:44:12 +00:00
renovate[bot]
6fd8b2506c chore(deps): update dependency @edx/frontend-platform to v8.5.2 (#1289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 05:44:08 +00:00
renovate[bot]
cf382b89ed chore(deps): update dependency @openedx/paragon to v23.14.9 (#1286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 06:14:41 +00:00
edX requirements bot
9306d45284 chore: update browserslist DB (#1285)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-10-20 00:16:20 +00:00
renovate[bot]
773fdaba28 fix(deps): update dependency core-js to v3.46.0 (#1283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 04:29:50 +00:00
renovate[bot]
b07a7602e4 chore(deps): update dependency @openedx/paragon to v23.14.8 (#1282)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 04:29:41 +00:00
edX requirements bot
d0416cdcd2 chore: update browserslist DB (#1281)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-10-13 00:15:38 +00:00
renovate[bot]
8f78079112 chore(deps): update dependency @openedx/paragon to v23.14.4 (#1278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 04:38:45 +00:00
renovate[bot]
4956d8966f chore(deps): update dependency @testing-library/jest-dom to v6.9.1 (#1279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-06 04:38:30 +00:00
edX requirements bot
20eb6b9de3 chore: update browserslist DB (#1277)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-10-06 00:14:55 +00:00
Feanil Patel
b43f088f9f Merge pull request #1274 from openedx/feanil/remove-reactifex-packages
build: remove unused reactifex packages
2025-09-29 10:54:51 -04:00
renovate[bot]
5f4d620b1e chore(deps): update dependency @openedx/paragon to v23.14.3 (#1275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-29 14:52:29 +00:00
Feanil Patel
a7ef931ef5 build: remove unused reactifex packages
Remove 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.

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 10:49:42 -04:00
Feanil Patel
1ac78e2752 Merge pull request #1267 from raccoongang/Artur_Filippovskii/node24-upgrade-3
test: Remove support for Node 20
2025-09-29 10:48:19 -04:00
artur.filippovskii
2867ea653b test: Remove support for Node 20 2025-09-29 09:53:49 +03:00
artur.filippovskii
3f71adec02 build: Upgrade to Node 24 2025-09-26 11:56:19 -03:00
artur.filippovskii
e33573e503 test: Add Node 24 to CI matrix 2025-09-26 09:50:36 -03:00
renovate[bot]
1b2b34e0e4 chore(deps): update dependency @testing-library/jest-dom to v6.8.0 (#1273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 05:36:18 +00:00
renovate[bot]
5f34256118 fix(deps): update dependency core-js to v3.45.1 (#1272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 05:35:47 +00:00
edX requirements bot
d9ab9a9c4c chore: update browserslist DB (#1271)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-09-22 00:16:14 +00:00
renovate[bot]
29979a57e1 fix(deps): update dependency @edx/frontend-platform to v8.5.1 (#1269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 04:35:36 +00:00
renovate[bot]
97144ba002 fix(deps): update dependency @edx/frontend-component-footer to v14.9.2 (#1268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 04:35:17 +00:00
renovate[bot]
10bb6e1b3e fix(deps): update dependency @edx/frontend-component-header to v6.6.1 (#1265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 04:51:26 +00:00
renovate[bot]
4a37b68550 fix(deps): update dependency @edx/frontend-component-footer to v14.9.1 (#1264)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 04:51:02 +00:00
edX requirements bot
f50c77e051 chore: update browserslist DB (#1263)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-09-08 00:15:47 +00:00
Samuel Allan
67c3ce6402 fix: update frontend-build to fix install issues (#1260)
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:47:08 -06:00
edX requirements bot
3a7a443d5b chore: update browserslist DB (#1259)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-09-01 00:17:47 +00:00
renovate[bot]
7c7e472fb2 fix(deps): update dependency @openedx/paragon to v23.14.2 (#1258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 05:07:17 +00:00
renovate[bot]
c74c62f5a0 fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.6 (#1257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 05:07:05 +00:00
edX requirements bot
8e1c4f06c4 chore: update browserslist DB (#1256)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-08-25 00:16:16 +00:00
Simone Saturno
d2ed3e54ee refactor: replaced injectIntl with useIntl (#1239)
* refactor: replaced injectIntl with useIntl

* test: update tests for useIntl hook implementation

* fix: add missing trailing comma

* test: fix failing component tests and remove deprecated defaultProps

- Fix SwitchContent component defaultProps warning with default parameters
- Fix Visibility component formatMessage errors and remove defaultProps
- Fix FormControls component intl provider issues with useIntl mock
- Fix EditButton component defaultProps and message formatting
- Update EditableItemHeader, EmptyContent components
- Replace React defaultProps with ES6 default parameters across components
- Update test mocking to properly handle useIntl hook
- All 82 tests now pass (previously 4 failed, 78 passed)

Resolves React deprecation warnings and modernizes component patterns.

* fix: add missing trailing comma
2025-08-18 10:00:13 -04:00
edX requirements bot
c73d1f96a0 chore: update browserslist DB (#1255)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-08-18 00:17:07 +00:00
Brayan Cerón
8b88de618d feat: add slot to extend the profile fields (#1211)
* feat: add extended profile fields functionality with context and form components

* refactor: replace string literals with FORM_MODE constants in profile fields components

* feat: implement BaseField component and refactor field elements to use it

* chore: remove unused webpack development configuration file

* feat: refactor extended profile fields implementation and remove unused components

* feat: update dependencies for frontend-plugin-framework and remove unused dompurify

* refactor: simplify pluginProps structure in ExtendedProfileFieldsSlot component

* feat: add README and example images for Extended Profile Fields slot

* refactor: improve performance & keep consistency

* feat: add Additional Profile Fields slot with example implementation and documentation

* feat: update custom fields image for Additional Profile Fields slot

* fix: reorder import of AdditionalProfileFieldsSlot for consistency

* test: fix snapshot

* fix: adjust margin in example to avoid oddities on mobile

* fix: remove unnecessary empty divs from ProfilePage snapshots
2025-08-13 15:25:45 -04:00
ayesha waris
872fa4c917 test: added test cases to improve test coverage (#1254)
Co-authored-by: Ayesha Waris <ayesha.waris@192.168.1.75>
2025-08-13 15:21:09 +05:00
renovate[bot]
cdf19f4ba5 fix(deps): update dependency core-js to v3.45.0 (#1253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 05:22:00 +00:00
renovate[bot]
67af9d0f8d fix(deps): update dependency @edx/frontend-platform to v8.5.0 (#1252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-11 05:21:51 +00:00
edX requirements bot
7ac0e21741 chore: update browserslist DB (#1251)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-08-11 00:17:23 +00:00
Eemaan Amir
25a0f08850 fix: fixed styling of buttons (#1250)
* fix: fixed styling of buttons

* test: updated test snapshots
2025-08-07 16:17:55 +05:00
renovate[bot]
cb0af0df7b fix(deps): update dependency @openedx/paragon to v23.14.1 (#1248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 05:43:14 +00:00
renovate[bot]
23f8b5a58c fix(deps): update dependency @edx/frontend-component-header to v6.6.0 (#1249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 05:42:43 +00:00
edX requirements bot
ea50a3ec10 chore: update browserslist DB (#1247)
Co-authored-by: jsnwesson <62807795+jsnwesson@users.noreply.github.com>
2025-08-04 00:17:53 +00:00
Kyle McCormick
31a0e43a92 chore: Delete CODEOWNERS (#1245)
See: https://github.com/openedx/axim-engineering/issues/1511
2025-07-31 16:18:14 -04:00
renovate[bot]
927fb56c8b fix(deps): update dependency @fortawesome/react-fontawesome to v0.2.3 (#1244)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 06:50:28 +00:00
renovate[bot]
1f88d5752f chore(deps): update dependency @testing-library/jest-dom to v6.6.4 (#1243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-28 06:50:07 +00:00
renovate[bot]
58e14019dc fix(deps): update dependency core-js to v3.44.0 (#1237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 07:13:53 +00:00
renovate[bot]
e373c7da75 fix(deps): update dependency @edx/frontend-component-header to v6.4.2 (#1236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-14 07:13:40 +00:00
Eemaan Amir
2b3857d922 fix: added country code list to profilePage state (#1235) 2025-07-11 18:43:36 +05:00
Eemaan Amir
863937be88 fix: added DISABLE_VISIBILITY_EDITING flag to index (#1234) 2025-07-11 17:33:47 +05:00
Eemaan Amir
9d5a89d4c6 feat: removed flag for new profile UI and depreacted old code (#1233) 2025-07-11 11:09:13 +05:00
Awais Ansari
762e29047f feat: merge 2u-main into master (#1231)
* feat: added country disabling feature (#1084)

* feat: added country disabling feature

* fix: lint errors

* test: added test case for disabled countries

* refactor: combined test cases

* feat: reskin of Profile MFE main page (#1114)

* feat: reskin of Profile MFE main page

* feat: reskin of Profile MFE main page

* test: updated tests according to the changes

* fix: added missing name property

* test: updated test snapshot

* test: added tests for reducers

* feat: moved reskin logic behind env variable

* test: updated tests

* refactor: refactored code according to requested changes

* fix: fixed lint errors

* refactor: refactored code according to requested changes

* refactor: refactored code according to requested changes

* feat: fixed reloading issue

* fix: fixed responsiveness issues on mobile view (#1133)

* fix: fixed reponsiveness issues on mobile view

* test: updated tests

* refactor: refactored code as requested

* test: added not found test case

* test: updated test cases

* feat: added restricted country functionality

* fix: fixed test cases

* test: updated snapshot

* test: updated test cases

* feat: made profile editable (#1212)

* feat: readded editable fields to new profile view

* feat: made fullname editable and updated design

* test: updated test cases

* refactor: refactored code based on reviews

* feat: made fullname uneditable and added redirect link (#1215)

* feat: made fullname uneditable and added redirect link

* refactor: refactored code

* refactor: refactored code

* chore: rebase 2u-main with master (#1225)

* fix(deps): update dependency @edx/frontend-platform to v8.3.3 (#1187)

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

* fix(deps): update dependency core-js to v3.41.0 (#1188)

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

* chore(deps): update `@openedx` dependencies to versions that support React 18 (#1189)

* chore(deps): update dependency @openedx/frontend-build to v14.4.1 (#1191)

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

* fix(deps): update react-router monorepo to v6.30.0 (#1192)

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

* feat: upgrade react to v18 (#1190)

* fix(deps): update dependency @edx/frontend-platform to v8.3.4 (#1195)

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

* fix(deps): update dependency @edx/frontend-component-footer to v14.4.0 (#1196)

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

* fix(deps): update dependency @edx/frontend-component-header to v6.3.0 (#1197)

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

* fix(deps): update dependency @edx/frontend-component-footer to v14.6.0 (#1199)

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

* fix(deps): update dependency @edx/openedx-atlas to ^0.7.0 (#1200)

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

* fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#1201)

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

* feat: import `FooterSlot` from component package instead of slot package (#1198)

* chore(deps): update dependency glob to v11.0.2 (#1202)

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

* fix(deps): update dependency @edx/frontend-component-footer to v14.7.0 (#1203)

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

* fix(deps): update dependency @edx/frontend-component-footer to v14.7.1 (#1204)

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

* fix(deps): update dependency core-js to v3.42.0 (#1205)

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

* fix(deps): update dependency @edx/frontend-platform to v8.3.6 (#1207)

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

* chore(deps): update commitlint monorepo to v19.8.1 (#1206)

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

* fix(deps): update dependency @edx/frontend-platform to v8.3.7 (#1209)

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

* fix(deps): update dependency @edx/frontend-component-footer to v14.7.2 (#1213)

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

* fix(deps): update react-router monorepo to v6.30.1 (#1214)

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

* fix(deps): update dependency @edx/frontend-platform to v8.3.9 (#1216)

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

* fix(deps): update dependency @edx/frontend-component-footer to v14.9.0 (#1217)

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

* feat!: add design tokens support (#1220)

BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Diana Olarte <diana.olarte@edunext.co>

* fix: fixed lint issues

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
Co-authored-by: Hunia Fatima <huniafatima99@gmail.com>
Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>

* fix: fixed font sizing issue after applying elm theme (#1228)

* Revert "fix: fixed font sizing issue after applying elm theme (#1228)" (#1232)

This reverts commit a9159f6613.

---------

Co-authored-by: muhammadadeeltajamul <muhammadadeeltajamul@hotmail.com>
Co-authored-by: Muhammad Adeel Tajamul <77053848+muhammadadeeltajamul@users.noreply.github.com>
Co-authored-by: Eemaan Amir <57627710+eemaanamir@users.noreply.github.com>
Co-authored-by: sundasnoreen12 <sundasnoreen12@gmail.com>
Co-authored-by: sundasnoreen12 <72802712+sundasnoreen12@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Brian Smith <112954497+brian-smith-tcril@users.noreply.github.com>
Co-authored-by: Hunia Fatima <huniafatima99@gmail.com>
Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
Co-authored-by: eemaanamir <eemaan.amir@gmail.com>
2025-07-10 10:12:21 +05:00
renovate[bot]
fa7267f17c fix(deps): update dependency @openedx/paragon to v23.14.0 (#1230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 06:54:42 +00:00
renovate[bot]
7b754edef8 fix(deps): update dependency @edx/frontend-component-header to v6.4.1 (#1229)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 06:54:14 +00:00
renovate[bot]
0bb7ee2fd4 fix(deps): update dependency core-js to v3.43.0 (#1223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 11:39:56 +00:00
renovate[bot]
335dd7819d chore(deps): update dependency glob to v11.0.3 (#1222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 11:39:43 +00:00
renovate[bot]
b18a01c302 fix(deps): update dependency @openedx/paragon to v23.13.0 (#1226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-30 05:06:16 +00:00
Brian Smith
981dccf2d5 feat!: add design tokens support (#1220)
BREAKING CHANGE: Pre-design-tokens theming is no longer supported.

Co-authored-by: Diana Olarte <diana.olarte@edunext.co>
2025-06-18 12:13:59 -04:00
renovate[bot]
e8be148ca9 fix(deps): update dependency @edx/frontend-component-footer to v14.9.0 (#1217)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 08:03:40 +00:00
renovate[bot]
167a8bd9a8 fix(deps): update dependency @edx/frontend-platform to v8.3.9 (#1216)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 07:58:23 +00:00
renovate[bot]
db2336ac09 fix(deps): update react-router monorepo to v6.30.1 (#1214)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 06:07:45 +00:00
renovate[bot]
a13c25d4ea fix(deps): update dependency @edx/frontend-component-footer to v14.7.2 (#1213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 06:07:26 +00:00
renovate[bot]
9452b72525 fix(deps): update dependency @edx/frontend-platform to v8.3.7 (#1209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 13:58:23 +00:00
renovate[bot]
3601cb6c05 chore(deps): update commitlint monorepo to v19.8.1 (#1206)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 07:18:40 +00:00
renovate[bot]
b73c0f0f26 fix(deps): update dependency @edx/frontend-platform to v8.3.6 (#1207)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 07:07:33 +00:00
renovate[bot]
37feffc0db fix(deps): update dependency core-js to v3.42.0 (#1205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 06:10:05 +00:00
renovate[bot]
c9d2813009 fix(deps): update dependency @edx/frontend-component-footer to v14.7.1 (#1204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 06:09:50 +00:00
renovate[bot]
8ec67d9ed2 fix(deps): update dependency @edx/frontend-component-footer to v14.7.0 (#1203)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-28 06:04:06 +00:00
renovate[bot]
1d149f12ea chore(deps): update dependency glob to v11.0.2 (#1202)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-28 06:03:48 +00:00
Brian Smith
9516ee0e92 feat: import FooterSlot from component package instead of slot package (#1198) 2025-04-24 12:11:20 -04:00
renovate[bot]
29fd7176c8 fix(deps): update dependency @edx/frontend-component-header to v6.4.0 (#1201)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-23 20:18:38 +00:00
renovate[bot]
16ddd7abba fix(deps): update dependency @edx/openedx-atlas to ^0.7.0 (#1200)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 05:46:19 +00:00
renovate[bot]
577ef6ab0b fix(deps): update dependency @edx/frontend-component-footer to v14.6.0 (#1199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 05:46:13 +00:00
renovate[bot]
8268fa4eab fix(deps): update dependency @edx/frontend-component-header to v6.3.0 (#1197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 06:11:54 +00:00
renovate[bot]
5665f8a0d6 fix(deps): update dependency @edx/frontend-component-footer to v14.4.0 (#1196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 06:11:46 +00:00
renovate[bot]
4b7a3207e0 fix(deps): update dependency @edx/frontend-platform to v8.3.4 (#1195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 07:51:52 +00:00
Hunia Fatima
3db0289aab feat: upgrade react to v18 (#1190) 2025-04-04 11:16:23 -04:00
renovate[bot]
85d3eca9e4 fix(deps): update react-router monorepo to v6.30.0 (#1192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 06:47:19 +00:00
renovate[bot]
f7fd2959ac chore(deps): update dependency @openedx/frontend-build to v14.4.1 (#1191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 06:47:09 +00:00
Brian Smith
8652206aa4 chore(deps): update @openedx dependencies to versions that support React 18 (#1189) 2025-03-27 16:17:12 -04:00
renovate[bot]
7a5e03967d fix(deps): update dependency core-js to v3.41.0 (#1188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 06:30:58 +00:00
renovate[bot]
da19dfaadc fix(deps): update dependency @edx/frontend-platform to v8.3.3 (#1187)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-24 06:30:34 +00:00
renovate[bot]
60d960276d fix(deps): update dependency @edx/frontend-platform to v8.3.1 (#1178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-18 11:02:27 +00:00
Awais Ansari
c8a6f9fbd8 test: updated profile test cases (#1182) 2025-03-18 15:58:48 +05:00
119 changed files with 18532 additions and 23913 deletions

4
.env
View File

@@ -29,4 +29,6 @@ APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL=''
ENABLE_SKILLS_BUILDER_PROFILE=''
ENABLE_NEW_PROFILE_VIEW=''
# Fallback in local style files
PARAGON_THEME_URLS={}
DISABLE_VISIBILITY_EDITING=''

View File

@@ -30,4 +30,6 @@ APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL='http://localhost:18000/courses'
ENABLE_SKILLS_BUILDER_PROFILE=''
ENABLE_NEW_PROFILE_VIEW=''
# Fallback in local style files
PARAGON_THEME_URLS={}
DISABLE_VISIBILITY_EDITING=''

View File

@@ -25,4 +25,5 @@ LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'
COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
ENABLE_NEW_PROFILE_VIEW=''
PARAGON_THEME_URLS={}
DISABLE_VISIBILITY_EDITING=''

1
.github/CODEOWNERS vendored
View File

@@ -1 +0,0 @@
* @openedx/2U-infinity

2
.nvmrc
View File

@@ -1 +1 @@
20
24

17320
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"dev": "PUBLIC_PATH=/profile/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"stubs": "pact-stub-service ./src/pacts/frontend-app-profile-edx-platform.json --port 18000"
},
@@ -29,32 +30,33 @@
],
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-platform": "8.1.5",
"@edx/openedx-atlas": "^0.6.0",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-brands-svg-icons": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@openedx/frontend-slot-footer": "^1.0.2",
"@openedx/paragon": "^22.2.2",
"@fortawesome/react-fontawesome": "0.2.6",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@pact-foundation/pact": "^11.0.2",
"@redux-devtools/extension": "3.3.0",
"classnames": "2.5.1",
"core-js": "3.40.0",
"core-js": "3.46.0",
"history": "5.3.0",
"lodash.camelcase": "4.3.0",
"lodash.get": "4.4.2",
"lodash.pick": "4.4.0",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.29.0",
"react-router-dom": "6.29.0",
"react-router": "6.30.1",
"react-router-dom": "6.30.1",
"redux": "4.2.1",
"redux-logger": "3.0.6",
"redux-saga": "1.3.0",
@@ -64,15 +66,13 @@
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@commitlint/cli": "19.8.0",
"@commitlint/config-angular": "19.8.0",
"@commitlint/cli": "19.8.1",
"@commitlint/config-angular": "19.8.1",
"@edx/browserslist-config": "^1.1.1",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "14.3.2",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "12.1.5",
"glob": "11.0.1",
"reactifex": "1.1.1",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "14.3.1",
"glob": "11.0.3",
"redux-mock-store": "1.5.5"
}
}

View File

@@ -1,14 +1,9 @@
import { combineReducers } from 'redux';
import { getConfig } from '@edx/frontend-platform';
import { reducer as profilePageReducer } from '../profile';
import { reducer as newProfilePageReducer } from '../profile-v2';
const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW;
const createRootReducer = () => combineReducers({
profilePage: isNewProfileEnabled ? newProfilePageReducer : profilePageReducer,
profilePage: profilePageReducer,
});
export default createRootReducer;

View File

@@ -1,12 +1,8 @@
import { all } from 'redux-saga/effects';
import { getConfig } from '@edx/frontend-platform';
import { saga as profileSaga } from '../profile';
import { saga as newProfileSaga } from '../profile-v2';
const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW;
export default function* rootSaga() {
yield all([
isNewProfileEnabled ? newProfileSaga() : profileSaga(),
profileSaga(),
]);
}

View File

@@ -1,21 +1,26 @@
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['profile.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['profile.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

@@ -1,8 +0,0 @@
@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";
@import './profile-v2/index';

View File

@@ -7,7 +7,6 @@ import {
initialize,
mergeConfig,
subscribe,
getConfig,
} from '@edx/frontend-platform';
import {
AppProvider,
@@ -15,10 +14,11 @@ import {
} from '@edx/frontend-platform/react';
import React from 'react';
import ReactDOM from 'react-dom';
// eslint-disable-next-line import/no-unresolved
import { createRoot } from 'react-dom/client';
import Header from '@edx/frontend-component-header';
import FooterSlot from '@openedx/frontend-slot-footer';
import { FooterSlot } from '@edx/frontend-component-footer';
import messages from './i18n';
import configureStore from './data/configureStore';
@@ -27,19 +27,16 @@ import Head from './head/Head';
import AppRoutes from './routes/AppRoutes';
import './index.scss';
const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, async () => {
const isNewProfileEnabled = getConfig().ENABLE_NEW_PROFILE_VIEW === 'true';
if (isNewProfileEnabled) {
await import('./index-v2.scss');
} else {
await import('./index.scss');
}
ReactDOM.render(
rootNode.render(
<AppProvider store={configureStore()}>
<Head />
<Header />
<main id="main">
<AppRoutes isNewProfileEnabled={isNewProfileEnabled} />
<AppRoutes />
</main>
<FooterSlot />
</AppProvider>,
@@ -48,7 +45,7 @@ subscribe(APP_READY, async () => {
});
subscribe(APP_INIT_ERROR, (error) => {
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
rootNode.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({
@@ -59,7 +56,7 @@ initialize({
mergeConfig({
COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH,
ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE,
ENABLE_NEW_PROFILE_VIEW: process.env.ENABLE_NEW_PROFILE_VIEW || null,
DISABLE_VISIBILITY_EDITING: process.env.DISABLE_VISIBILITY_EDITING,
}, 'App loadConfig override handler');
},
},

View File

@@ -1,8 +1,6 @@
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@openedx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";
@import './profile/index';
@import 'profile/index';

View File

@@ -36,7 +36,11 @@
"dateJoined": "2017-06-07T00:44:23Z",
"email": "staff@example.com",
"isActive": true,
"languageProficiencies": [],
"levelOfEducation": null,
"name": "Lemon Seltzer",
"profileImage": {},
"socialLinks": [],
"username": "staff",
"yearOfBirth": 1901
},

View File

@@ -0,0 +1,97 @@
# Additional Profile Fields
### Slot ID: `org.openedx.frontend.profile.additional_profile_fields.v1`
## Description
This slot is used to replace/modify/hide the additional profile fields in the profile page.
## Example
The following `env.config.jsx` will extend the default fields with a additional custom fields through a simple example component.
![Screenshot of Custom Fields](./images/custom_fields.png)
### Using the Additional Fields Component
Create a file named `env.config.jsx` at the MFE root with this:
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
import Example from './src/plugin-slots/AdditionalProfileFieldsSlot/example';
const config = {
pluginSlots: {
'org.openedx.frontend.profile.additional_profile_fields.v1': {
plugins: [
{
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'additional_profile_fields',
type: DIRECT_PLUGIN,
RenderWidget: Example,
},
},
],
},
},
};
export default config;
```
## Plugin Props
When implementing a plugin for this slot, the following props are available:
### `updateUserProfile`
- **Type**: Function
- **Description**: A function for updating the user's profile with new field values. This handles the API call to persist changes to the backend.
- **Usage**: Pass an object containing the field updates to be saved to the user's profile. The function automatically handles the persistence and UI updates.
#### Example
```javascript
updateUserProfile({ extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
```
### `profileFieldValues`
- **Type**: Array of Objects
- **Description**: Contains the current values of all additional profile fields as an array of objects. Each object has a `fieldName` property (string) and a `fieldValue` property (which can be string, boolean, number, or other data types depending on the field type).
- **Usage**: Access specific field values by finding the object with the matching `fieldName` and reading its `fieldValue` property. Use array methods like `find()` to locate specific fields.
#### Example
```javascript
// Finding a specific field value
const nifField = profileFieldValues.find(field => field.fieldName === 'nif');
const nifValue = nifField ? nifField.fieldValue : null;
// Example data structure:
[
{
"fieldName": "favorite_color",
"fieldValue": "red"
},
{
"fieldName": "employment_situation",
"fieldValue": "Unemployed"
},
]
```
### `profileFieldErrors`
- **Type**: Object
- **Description**: Contains validation errors for profile fields. Each key corresponds to a field name, and the value is the error message.
- **Usage**: Check for field-specific errors to display validation feedback to users.
### `formComponents`
- **Type**: Object
- **Description**: Provides access to reusable form components that are consistent with the rest of the profile page styling and behavior. These components follow the platform's design system and include proper validation and accessibility features.
- **Usage**: Use these components in your custom fields implementation to maintain UI consistency. Available components include `SwitchContent` for managing different UI states, `EmptyContent` for empty states, and `EditableItemHeader` for consistent headers.
### `refreshUserProfile`
- **Type**: Function
- **Description**: A function that triggers a refresh of the user's profile data. This can be used after updating profile fields to ensure the UI reflects the latest data from the server.
- **Usage**: Call this function with the username parameter when you need to manually reload the user profile information. Note that `updateUserProfile` typically handles data refresh automatically.
#### Example
```javascript
refreshUserProfile(username);
```

View File

@@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Button } from '@openedx/paragon';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
/**
* Straightforward example of how you could use the pluginProps provided by
* the AdditionalProfileFieldsSlot to create a custom profile field.
*
* Here you can set a 'favorite_color' field with radio buttons and
* save it to the user's profile, especifically to their `meta` in
* the user's model. For more information, see the documentation:
*
* https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/user_api/README.rst#persisting-optional-user-metadata
*/
const Example = ({
updateUserProfile,
profileFieldValues,
profileFieldErrors,
formComponents: { SwitchContent, EditableItemHeader, EmptyContent } = {},
}) => {
const authenticatedUser = getAuthenticatedUser();
const [formMode, setFormMode] = useState('editable');
// Get current favorite color from profileFieldValues
const currentColorField = profileFieldValues?.find(field => field.fieldName === 'favorite_color');
const currentColor = currentColorField ? currentColorField.fieldValue : '';
const [value, setValue] = useState(currentColor);
const handleChange = e => setValue(e.target.value);
// Get any validation errors for the favorite_color field
const colorFieldError = profileFieldErrors?.favorite_color;
useEffect(() => {
if (!value) { setFormMode('empty'); }
if (colorFieldError) {
setFormMode('editing');
}
}, [colorFieldError, value]);
const handleSubmit = () => {
try {
updateUserProfile(authenticatedUser.username, { extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
setFormMode('editable');
} catch (error) {
setFormMode('editing');
}
};
return (
<div className="border border-accent-500 p-3 mt-5">
<h3 className="h3">Example Additional Profile Fields Slot</h3>
<SwitchContent
className="pt-40px"
expression={formMode}
cases={{
editing: (
<>
<label className="edit-section-header" htmlFor="favorite_color">
Favorite Color
</label>
<input
className="form-control"
id="favorite_color"
name="favorite_color"
value={value}
onChange={handleChange}
/>
<Button type="button" className="mt-2" onClick={handleSubmit}>
Save
</Button>
</>
),
editable: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
Favorite Color
</p>
</div>
<EditableItemHeader
content={value}
showEditButton
onClickEdit={() => setFormMode('editing')}
showVisibility={false}
visibility="private"
/>
</>
),
empty: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
Favorite Color
</p>
</div>
<EmptyContent onClick={() => setFormMode('editing')}>
<p className="mb-0">Click to add your favorite color</p>
</EmptyContent>
</>
),
}}
/>
</div>
);
};
Example.propTypes = {
updateUserProfile: PropTypes.func.isRequired,
profileFieldValues: PropTypes.arrayOf(
PropTypes.shape({
fieldName: PropTypes.string.isRequired,
fieldValue: PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,
]).isRequired,
}),
),
profileFieldErrors: PropTypes.objectOf(PropTypes.string),
formComponents: PropTypes.shape({
SwitchContent: PropTypes.elementType.isRequired,
}),
};
export default Example;

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@@ -0,0 +1,37 @@
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import { useDispatch, useSelector } from 'react-redux';
import { useCallback } from 'react';
import { patchProfile } from '../../profile/data/services';
import { fetchProfile } from '../../profile/data/actions';
import SwitchContent from '../../profile/forms/elements/SwitchContent';
import EmptyContent from '../../profile/forms/elements/EmptyContent';
import EditableItemHeader from '../../profile/forms/elements/EditableItemHeader';
const AdditionalProfileFieldsSlot = () => {
const dispatch = useDispatch();
const extendedProfileValues = useSelector((state) => state.profilePage.account.extendedProfile);
const errors = useSelector((state) => state.profilePage.errors);
const pluginProps = {
refreshUserProfile: useCallback((username) => dispatch(fetchProfile(username)), [dispatch]),
updateUserProfile: patchProfile,
profileFieldValues: extendedProfileValues,
profileFieldErrors: errors,
formComponents: {
SwitchContent,
EmptyContent,
EditableItemHeader,
},
};
return (
<PluginSlot
id="org.openedx.frontend.profile.additional_profile_fields.v1"
pluginProps={pluginProps}
/>
);
};
export default AdditionalProfileFieldsSlot;

View File

@@ -1,12 +1,15 @@
# Footer Slot
### Slot ID: `footer_slot`
### Slot ID: `org.openedx.frontend.layout.footer.v1`
### Slot ID Aliases
* `footer_slot`
## Description
This slot is used to replace/modify/hide the footer.
The implementation of the `FooterSlot` component lives in [the `frontend-slot-footer` repository](https://github.com/openedx/frontend-slot-footer/).
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
## Example
@@ -23,7 +26,7 @@ import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-frame
const config = {
pluginSlots: {
footer_slot: {
'org.openedx.frontend.layout.footer.v1': {
plugins: [
{
// Hide the default footer

View File

@@ -1,3 +1,3 @@
# `frontend-app-profile` Plugin Slots
* [`footer_slot`](./FooterSlot/)
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)

View File

@@ -1,29 +0,0 @@
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
const DateJoined = ({ date }) => {
if (!date) { return null; }
return (
<span className="small mb-0 text-gray-800">
<FormattedMessage
id="profile.datejoined.member.since"
defaultMessage="Member since {year}"
description="A label for how long the user has been a member"
values={{
year: <span className="font-weight-bold"> <FormattedDate value={new Date(date)} year="numeric" /> </span>,
}}
/>
</span>
);
};
DateJoined.propTypes = {
date: PropTypes.string,
};
DateJoined.defaultProps = {
date: null,
};
export default memo(DateJoined);

View File

@@ -1,16 +0,0 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted max-width-32em">
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
export default NotFoundPage;

View File

@@ -1,18 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
const PageLoading = ({ srMessage }) => (
<div>
<div className="d-flex justify-content-center align-items-center flex-column height-50vh">
<div className="spinner-border text-primary" role="status">
{srMessage && <span className="sr-only">{srMessage}</span>}
</div>
</div>
</div>
);
PageLoading.propTypes = {
srMessage: PropTypes.string.isRequired,
};
export default PageLoading;

View File

@@ -1,215 +0,0 @@
import React, {
useEffect, useState, useContext, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@openedx/paragon';
import classNames from 'classnames';
import {
fetchProfile,
saveProfilePhoto,
deleteProfilePhoto,
} from './data/actions';
import ProfileAvatar from './forms/ProfileAvatar';
import Certificates from './Certificates';
import DateJoined from './DateJoined';
import UserCertificateSummary from './UserCertificateSummary';
import UsernameDescription from './UsernameDescription';
import PageLoading from './PageLoading';
import { profilePageSelector } from './data/selectors';
import messages from './ProfilePage.messages';
import withParams from '../utils/hoc';
import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
const ProfilePage = ({ params }) => {
const dispatch = useDispatch();
const intl = useIntl();
const context = useContext(AppContext);
const {
requiresParentalConsent,
dateJoined,
yearOfBirth,
courseCertificates,
name,
profileImage,
savePhotoState,
isLoadingProfile,
photoUploadError,
} = useSelector(profilePageSelector);
const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null);
const isMobileView = useIsOnMobileScreen();
const isTabletView = useIsOnTabletScreen();
useEffect(() => {
const { CREDENTIALS_BASE_URL } = context.config;
if (CREDENTIALS_BASE_URL) {
setViewMyRecordsUrl(`${CREDENTIALS_BASE_URL}/records`);
}
dispatch(fetchProfile(params.username));
sendTrackingLogEvent('edx.profile.viewed', {
username: params.username,
});
}, [dispatch, params.username, context.config]);
const handleSaveProfilePhoto = useCallback((formData) => {
dispatch(saveProfilePhoto(context.authenticatedUser.username, formData));
}, [dispatch, context.authenticatedUser.username]);
const handleDeleteProfilePhoto = useCallback(() => {
dispatch(deleteProfilePhoto(context.authenticatedUser.username));
}, [dispatch, context.authenticatedUser.username]);
const isYOBDisabled = () => {
const currentYear = new Date().getFullYear();
const isAgeOrNotCompliant = !yearOfBirth || ((currentYear - yearOfBirth) < 13);
return isAgeOrNotCompliant && getConfig().COLLECT_YEAR_OF_BIRTH !== 'true';
};
const isAuthenticatedUserProfile = () => params.username === context.authenticatedUser.username;
const isBlockVisible = (blockInfo) => isAuthenticatedUserProfile()
|| (!isAuthenticatedUserProfile() && Boolean(blockInfo));
const renderViewMyRecordsButton = () => {
if (!(viewMyRecordsUrl && isAuthenticatedUserProfile())) {
return null;
}
return (
<Hyperlink
className={classNames(
'btn btn-brand btn-rounded font-weight-normal px-4 py-0625rem text-nowrap',
{ 'btn-sm': isMobileView },
)}
target="_blank"
showLaunchIcon={false}
destination={viewMyRecordsUrl}
>
{intl.formatMessage(messages['profile.viewMyRecords'])}
</Hyperlink>
);
};
const renderPhotoUploadErrorMessage = () => (
photoUploadError && (
<div className="row">
<div className="col-md-4 col-lg-3">
<Alert variant="danger" dismissible={false} show>
{photoUploadError.userMessage}
</Alert>
</div>
</div>
)
);
return (
<div className="profile-page">
{isLoadingProfile ? (
<PageLoading srMessage={intl.formatMessage(messages['profile.loading'])} />
) : (
<>
<div
className={classNames(
'profile-page-bg-banner bg-primary d-md-block align-items-center py-4rem h-100 w-100',
{ 'px-4.5': isMobileView },
{ 'px-75rem': !isMobileView },
)}
>
<div className="col container-fluid w-100 h-100 bg-white py-0 px-25rem rounded-75">
<div className="col h-100 w-100 py-4 px-0 justify-content-start g-15rem">
<div
className={classNames([
'row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem',
isMobileView || isTabletView ? 'flex-column' : 'flex-row',
])}
>
<ProfileAvatar
className="col p-0"
src={profileImage.src}
isDefault={profileImage.isDefault}
onSave={handleSaveProfilePhoto}
onDelete={handleDeleteProfilePhoto}
savePhotoState={savePhotoState}
isEditable={isAuthenticatedUserProfile() && !requiresParentalConsent}
/>
<div
className={classNames([
'col h-100 w-100 m-0 p-0',
isMobileView || isTabletView
? 'd-flex flex-column justify-content-center align-items-center'
: 'justify-content-start align-items-start',
])}
>
<p className={classNames([
'row m-0 font-weight-bold text-truncate text-primary-500',
isMobileView ? 'h4' : 'h3',
])}
>
{params.username}
</p>
{isBlockVisible(name) && (
<p className={classNames([
'row pt-2 text-gray-800 font-weight-normal m-0',
isMobileView ? 'h5' : 'p',
])}
>
{name}
</p>
)}
<div className={classNames(
'row pt-2 m-0',
isMobileView
? 'd-flex justify-content-center align-items-center flex-column'
: 'g-1rem',
)}
>
<DateJoined date={dateJoined} />
<UserCertificateSummary count={courseCertificates.length} />
</div>
</div>
<div className={classNames([
'p-0 ',
isMobileView || isTabletView ? 'col d-flex justify-content-center' : 'col-auto',
])}
>
{renderViewMyRecordsButton()}
</div>
</div>
<div className="row-auto d-flex align-items-center h-100 w-100 justify-content-start m-0 pt-4">
{isYOBDisabled() && <UsernameDescription />}
</div>
</div>
<div className="ml-auto">
{renderPhotoUploadErrorMessage()}
</div>
</div>
</div>
<div className="col container-fluid d-inline-flex px-75rem pt-4rem pb-6 h-100 w-100 align-items-start justify-content-center g-3rem">
{isBlockVisible(courseCertificates.length) && (
<Certificates
certificates={courseCertificates}
formId="certificates"
/>
)}
</div>
</>
)}
</div>
);
};
ProfilePage.propTypes = {
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
};
export default withParams(ProfilePage);

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.viewMyRecords': {
id: 'profile.viewMyRecords',
defaultMessage: 'View My Records',
description: 'A link to go view my academic records',
},
'profile.loading': {
id: 'profile.loading',
defaultMessage: 'Profile loading...',
description: 'Message displayed when the profile data is loading.',
},
});
export default messages;

View File

@@ -1,185 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import * as analytics from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { render } from '@testing-library/react';
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import messages from '../i18n';
import ProfilePage from './ProfilePage';
import loadingApp from './__mocks__/loadingApp.mockStore';
import viewOwnProfile from './__mocks__/viewOwnProfile.mockStore';
import viewOtherProfile from './__mocks__/viewOtherProfile.mockStore';
const mockStore = configureMockStore([thunk]);
const storeMocks = {
loadingApp,
viewOwnProfile,
viewOtherProfile,
};
const requiredProfilePageProps = {
fetchUserAccount: () => {},
fetchProfile: () => {},
params: { username: 'staff' },
};
// Mock language cookie
Object.defineProperty(global.document, 'cookie', {
writable: true,
value: `${getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME}=en`,
});
jest.mock('@edx/frontend-platform/auth', () => ({
configure: () => {},
getAuthenticatedUser: () => null,
fetchAuthenticatedUser: () => null,
getAuthenticatedHttpClient: jest.fn(),
AUTHENTICATED_USER_CHANGED: 'user_changed',
}));
jest.mock('@edx/frontend-platform/analytics', () => ({
configure: () => {},
identifyAnonymousUser: jest.fn(),
identifyAuthenticatedUser: jest.fn(),
sendTrackingLogEvent: jest.fn(),
}));
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
beforeEach(() => {
analytics.sendTrackingLogEvent.mockReset();
});
const ProfilePageWrapper = ({
contextValue, store, params,
}) => (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={store}>
<ProfilePage {...requiredProfilePageProps} params={params} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
ProfilePageWrapper.defaultProps = {
params: { username: 'staff' },
};
ProfilePageWrapper.propTypes = {
contextValue: PropTypes.shape({}).isRequired,
store: PropTypes.shape({}).isRequired,
params: PropTypes.shape({}),
};
describe('<ProfilePage />', () => {
describe('Renders correctly in various states', () => {
it('app loading', () => {
const contextValue = {
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.loadingApp)} />;
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('viewing own profile', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.viewOwnProfile)} />;
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('viewing other profile with all fields', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore({
...storeMocks.viewOtherProfile,
profilePage: {
...storeMocks.viewOtherProfile.profilePage,
account: {
...storeMocks.viewOtherProfile.profilePage.account,
name: 'user',
country: 'EN',
bio: 'bio',
courseCertificates: ['course certificates'],
levelOfEducation: 'some level',
languageProficiencies: ['some lang'],
socialLinks: ['twitter'],
timeZone: 'time zone',
accountPrivacy: 'all_users',
},
},
})}
match={{ params: { username: 'verified' } }} // Override default match
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('without credentials service', () => {
const config = getConfig();
config.CREDENTIALS_BASE_URL = '';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.viewOwnProfile)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
});
describe('handles analytics', () => {
it('calls sendTrackingLogEvent when mounting', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.loadingApp)}
params={{ username: 'test-username' }}
/>,
);
expect(analytics.sendTrackingLogEvent.mock.calls.length).toBe(1);
expect(analytics.sendTrackingLogEvent.mock.calls[0][0]).toEqual('edx.profile.viewed');
expect(analytics.sendTrackingLogEvent.mock.calls[0][1]).toEqual({
username: 'test-username',
});
});
});
});

View File

@@ -1,22 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const UserCertificateSummary = ({ count = 0 }) => (
<span className="small m-0 text-gray-800">
<FormattedMessage
id="profile.certificatecount"
defaultMessage="{certificate_count} certifications"
description="A label for many certificates a user has"
values={{
certificate_count: <span className="font-weight-bold"> {count} </span>,
}}
/>
</span>
);
UserCertificateSummary.propTypes = {
count: PropTypes.number,
};
export default UserCertificateSummary;

View File

@@ -1,27 +0,0 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import classNames from 'classnames';
import { useIsOnMobileScreen } from './data/hooks';
const UsernameDescription = () => {
const isMobileView = useIsOnMobileScreen();
return (
<p className={classNames([
'text-gray-800 font-weight-normal m-0',
isMobileView ? 'h5' : 'p',
])}
>
<FormattedMessage
id="profile.username.description"
defaultMessage="Your learner records information is only visible to you. Only your username is visible to others on {siteName}."
description="A description of the username field"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
</p>
);
};
export default UsernameDescription;

View File

@@ -1,41 +0,0 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: null,
bio: null,
name: null,
country: null,
socialLinks: null,
profileImage: {
imageUrlMedium: null,
imageUrlLarge: null
},
levelOfEducation: null,
learningGoal: null
},
profilePage: {
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
account: {
username: 'staff',
socialLinks: []
},
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: true,
isAuthenticatedUserProfile: true,
},
router: {
location: {
pathname: '/u/staff',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -1,139 +0,0 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
levelOfEducation: 'el',
mailingAddress: null,
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: null,
},
profilePage: {
errors: {},
saveState: 'pending',
savePhotoState: null,
currentlyEditingField: 'bio',
isAuthenticatedUserProfile: true,
account: {
mailingAddress: null,
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
email: 'staff@example.com',
username: 'staff',
bio: 'This is my bio',
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
name: 'Lemon Seltzer',
secondaryEmail: null,
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom',
learningGoal: null,
},
preferences: {
visibilityUserLocation: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityCertificates: 'private',
visibilityLevelOfEducation: 'private',
visibilityCourseCertificates: 'all_users',
prefLang: 'en',
visibilityBio: 'all_users',
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
},
courseCertificates: [
{
username: 'staff',
status: 'downloadable',
courseDisplayName: 'edX Demonstration Course',
grade: '0.89',
courseId: 'course-v1:edX+DemoX+Demo_Course',
courseOrganization: 'edX',
modifiedDate: '2019-03-04T19:31:39.930255Z',
isPassing: true,
downloadUrl: 'http://www.example.com/',
certificateType: 'verified',
createdDate: '2019-03-04T19:31:39.896806Z'
}
],
drafts: {},
isLoadingProfile: false,
disabledCountries: [],
},
router: {
location: {
pathname: '/u/staff',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -1,98 +0,0 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
levelOfEducation: 'el',
mailingAddress: null,
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career',
},
profilePage: {
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
isAuthenticatedUserProfile: false,
account: {
mailingAddress: null,
profileImage: {
imageUrlFull: 'http://localhost:18000/static/images/profiles/default_500.png',
imageUrlLarge: 'http://localhost:18000/static/images/profiles/default_120.png',
imageUrlMedium: 'http://localhost:18000/static/images/profiles/default_50.png',
imageUrlSmall: 'http://localhost:18000/static/images/profiles/default_30.png',
hasImage: false
},
extendedProfile: [],
dateJoined: '2017-06-07T00:44:19Z',
accomplishmentsShared: false,
email: 'verified@example.com',
username: 'verified',
bio: null,
isActive: true,
yearOfBirth: null,
goals: null,
languageProficiencies: [],
courseCertificates: null,
requiresParentalConsent: true,
name: '',
secondaryEmail: null,
country: null,
socialLinks: [],
timeZone: null,
levelOfEducation: null,
gender: null,
accountPrivacy: 'private'
},
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: false,
learningGoal: 'advance_career',
},
router: {
location: {
pathname: '/u/verified',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -1,138 +0,0 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
levelOfEducation: 'el',
mailingAddress: null,
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career'
},
profilePage: {
errors: {},
saveState: null,
savePhotoState: null,
currentlyEditingField: null,
isAuthenticatedUserProfile: true,
account: {
mailingAddress: null,
profileImage: {
imageUrlFull: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012',
imageUrlLarge: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_120.jpg?v=1552495012',
imageUrlMedium: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_50.jpg?v=1552495012',
imageUrlSmall: 'http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_30.jpg?v=1552495012',
hasImage: true
},
extendedProfile: [],
dateJoined: '2017-06-07T00:44:23Z',
accomplishmentsShared: false,
email: 'staff@example.com',
username: 'staff',
bio: 'This is my bio',
isActive: true,
yearOfBirth: 1901,
goals: null,
languageProficiencies: [
{
code: 'yo'
}
],
courseCertificates: null,
requiresParentalConsent: false,
name: 'Lemon Seltzer',
secondaryEmail: null,
country: 'ME',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha'
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA'
}
],
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career'
},
preferences: {
visibilityUserLocation: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityCertificates: 'private',
visibilityLevelOfEducation: 'private',
visibilityCourseCertificates: 'all_users',
prefLang: 'en',
visibilityBio: 'all_users',
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
},
courseCertificates: [
{
username: 'staff',
status: 'downloadable',
courseDisplayName: 'edX Demonstration Course',
grade: '0.89',
courseId: 'course-v1:edX+DemoX+Demo_Course',
courseOrganization: 'edX',
modifiedDate: '2019-03-04T19:31:39.930255Z',
isPassing: true,
downloadUrl: 'http://www.example.com/',
certificateType: 'verified',
createdDate: '2019-03-04T19:31:39.896806Z'
}
],
drafts: {},
isLoadingProfile: false
},
router: {
location: {
pathname: '/u/staff',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -1,619 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ProfilePage /> Renders correctly in various states app loading 1`] = `
<div>
<div
class="profile-page"
>
<div>
<div
class="d-flex justify-content-center align-items-center flex-column height-50vh"
>
<div
class="spinner-border text-primary"
role="status"
>
<span
class="sr-only"
>
Profile loading...
</span>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states viewing other profile with all fields 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block align-items-center py-4rem h-100 w-100 px-75rem"
>
<div
class="col container-fluid w-100 h-100 bg-white py-0 px-25rem rounded-75"
>
<div
class="col h-100 w-100 py-4 px-0 justify-content-start g-15rem"
>
<div
class="row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem flex-row"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<iconmock
aria-hidden="true"
class="text-muted"
focusable="false"
role="img"
viewbox="0 0 24 24"
/>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
<div
class="col h-100 w-100 m-0 p-0 justify-content-start align-items-start"
>
<p
class="row m-0 font-weight-bold text-truncate text-primary-500 h3"
>
staff
</p>
<p
class="row pt-2 text-gray-800 font-weight-normal m-0 p"
>
user
</p>
<div
class="row pt-2 m-0 g-1rem"
>
<span
class="small mb-0 text-gray-800"
>
Member since
<span
class="font-weight-bold"
>
2017
</span>
</span>
<span
class="small m-0 text-gray-800"
>
<span
class="font-weight-bold"
>
0
</span>
certifications
</span>
</div>
</div>
<div
class="p-0 col-auto"
>
<a
class="pgn__hyperlink default-link standalone-link btn btn-brand btn-rounded font-weight-normal px-4 py-0625rem text-nowrap"
href="http://localhost:18150/records"
rel="noopener noreferrer"
target="_blank"
>
View My Records
</a>
</div>
</div>
<div
class="row-auto d-flex align-items-center h-100 w-100 justify-content-start m-0 pt-4"
>
<p
class="text-gray-800 font-weight-normal m-0 p"
>
Your learner records information is only visible to you. Only your username is visible to others on localhost.
</p>
</div>
</div>
<div
class="ml-auto"
/>
</div>
</div>
<div
class="col container-fluid d-inline-flex px-75rem pt-4rem pb-6 h-100 w-100 align-items-start justify-content-center g-3rem"
>
<div>
<div
class="col justify-content-start align-items-start g-5rem p-0"
>
<div
class="col align-self-stretch height-2625rem justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Your certificates
</p>
</div>
<div
class="col justify-content-start align-items-start pt-2 p-0"
>
<p
class="font-weight-normal text-gray-800 m-0 p-0 p"
>
Your learner records information is only visible to you. Only your username is visible to others on localhost.
</p>
</div>
</div>
<div
class="pt-5"
>
You don't have any certificates yet.
</div>
</div>
</div>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states viewing own profile 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block align-items-center py-4rem h-100 w-100 px-75rem"
>
<div
class="col container-fluid w-100 h-100 bg-white py-0 px-25rem rounded-75"
>
<div
class="col h-100 w-100 py-4 px-0 justify-content-start g-15rem"
>
<div
class="row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem flex-row"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<div
class="profile-avatar-menu-container"
>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle btn btn-primary"
type="button"
>
Change
</button>
</div>
</div>
<img
alt="profile avatar"
class="w-100 h-100 d-block rounded-circle overflow-hidden object-fit-cover"
data-hj-suppress="true"
src="http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012"
/>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
<div
class="col h-100 w-100 m-0 p-0 justify-content-start align-items-start"
>
<p
class="row m-0 font-weight-bold text-truncate text-primary-500 h3"
>
staff
</p>
<p
class="row pt-2 text-gray-800 font-weight-normal m-0 p"
>
Lemon Seltzer
</p>
<div
class="row pt-2 m-0 g-1rem"
>
<span
class="small mb-0 text-gray-800"
>
Member since
<span
class="font-weight-bold"
>
2017
</span>
</span>
<span
class="small m-0 text-gray-800"
>
<span
class="font-weight-bold"
>
1
</span>
certifications
</span>
</div>
</div>
<div
class="p-0 col-auto"
>
<a
class="pgn__hyperlink default-link standalone-link btn btn-brand btn-rounded font-weight-normal px-4 py-0625rem text-nowrap"
href="http://localhost:18150/records"
rel="noopener noreferrer"
target="_blank"
>
View My Records
</a>
</div>
</div>
<div
class="row-auto d-flex align-items-center h-100 w-100 justify-content-start m-0 pt-4"
/>
</div>
<div
class="ml-auto"
/>
</div>
</div>
<div
class="col container-fluid d-inline-flex px-75rem pt-4rem pb-6 h-100 w-100 align-items-start justify-content-center g-3rem"
>
<div>
<div
class="col justify-content-start align-items-start g-5rem p-0"
>
<div
class="col align-self-stretch height-2625rem justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Your certificates
</p>
</div>
<div
class="col justify-content-start align-items-start pt-2 p-0"
>
<p
class="font-weight-normal text-gray-800 m-0 p-0 p"
>
Your learner records information is only visible to you. Only your username is visible to others on localhost.
</p>
</div>
</div>
<div
class="col"
>
<div
class="row align-items-center pt-5 g-3rem"
>
<div
class="col-auto d-flex align-items-center p-0"
>
<div
class="col certificate p-4 border-light-400 bg-light-200 w-100 h-100"
>
<div
class="certificate-type-illustration"
style="background-image: url(icon/mock/path);"
/>
<div
class="d-flex flex-column position-relative p-0 width-19625rem"
>
<div
class="w-100 color-black"
>
<p
class="mb-0 font-weight-normal small"
>
Verified Certificate
</p>
<p
class="m-0 color-black h4"
>
edX Demonstration Course
</p>
<p
class="mb-0 small"
>
From
</p>
<h5
class="mb-0 color-black"
>
edX
</h5>
<p
class="mb-0 small"
>
Completed on
3/4/2019
</p>
</div>
<div
class="pt-3"
>
<a
class="pgn__hyperlink default-link standalone-link btn btn-primary btn-rounded font-weight-normal px-4 py-0625rem"
href="http://www.example.com/"
rel="noopener noreferrer"
target="_blank"
>
View Certificate
</a>
</div>
<p
class="mb-0 pt-3 small"
>
Credential ID
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states without credentials service 1`] = `
<div>
<div
class="profile-page"
>
<div
class="profile-page-bg-banner bg-primary d-md-block align-items-center py-4rem h-100 w-100 px-75rem"
>
<div
class="col container-fluid w-100 h-100 bg-white py-0 px-25rem rounded-75"
>
<div
class="col h-100 w-100 py-4 px-0 justify-content-start g-15rem"
>
<div
class="row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem flex-row"
>
<div
class="profile-avatar-wrap position-relative"
>
<div
class="profile-avatar rounded-circle bg-light"
>
<div
class="profile-avatar-menu-container"
>
<div
class="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="dropdown-toggle btn btn-primary"
type="button"
>
Change
</button>
</div>
</div>
<img
alt="profile avatar"
class="w-100 h-100 d-block rounded-circle overflow-hidden object-fit-cover"
data-hj-suppress="true"
src="http://localhost:18000/media/profile-images/d2a9bdc2ba165dcefc73265c54bf9a20_500.jpg?v=1552495012"
/>
</div>
<form
enctype="multipart/form-data"
>
<input
accept=".jpg, .jpeg, .png"
class="d-none form-control-file"
id="photo-file"
name="file"
type="file"
/>
</form>
</div>
<div
class="col h-100 w-100 m-0 p-0 justify-content-start align-items-start"
>
<p
class="row m-0 font-weight-bold text-truncate text-primary-500 h3"
>
staff
</p>
<p
class="row pt-2 text-gray-800 font-weight-normal m-0 p"
>
Lemon Seltzer
</p>
<div
class="row pt-2 m-0 g-1rem"
>
<span
class="small mb-0 text-gray-800"
>
Member since
<span
class="font-weight-bold"
>
2017
</span>
</span>
<span
class="small m-0 text-gray-800"
>
<span
class="font-weight-bold"
>
1
</span>
certifications
</span>
</div>
</div>
<div
class="p-0 col-auto"
/>
</div>
<div
class="row-auto d-flex align-items-center h-100 w-100 justify-content-start m-0 pt-4"
/>
</div>
<div
class="ml-auto"
/>
</div>
</div>
<div
class="col container-fluid d-inline-flex px-75rem pt-4rem pb-6 h-100 w-100 align-items-start justify-content-center g-3rem"
>
<div>
<div
class="col justify-content-start align-items-start g-5rem p-0"
>
<div
class="col align-self-stretch height-2625rem justify-content-start align-items-start p-0"
>
<p
class="font-weight-bold text-primary-500 m-0 h2"
>
Your certificates
</p>
</div>
<div
class="col justify-content-start align-items-start pt-2 p-0"
>
<p
class="font-weight-normal text-gray-800 m-0 p-0 p"
>
Your learner records information is only visible to you. Only your username is visible to others on localhost.
</p>
</div>
</div>
<div
class="col"
>
<div
class="row align-items-center pt-5 g-3rem"
>
<div
class="col-auto d-flex align-items-center p-0"
>
<div
class="col certificate p-4 border-light-400 bg-light-200 w-100 h-100"
>
<div
class="certificate-type-illustration"
style="background-image: url(icon/mock/path);"
/>
<div
class="d-flex flex-column position-relative p-0 width-19625rem"
>
<div
class="w-100 color-black"
>
<p
class="mb-0 font-weight-normal small"
>
Verified Certificate
</p>
<p
class="m-0 color-black h4"
>
edX Demonstration Course
</p>
<p
class="mb-0 small"
>
From
</p>
<h5
class="mb-0 color-black"
>
edX
</h5>
<p
class="mb-0 small"
>
Completed on
3/4/2019
</p>
</div>
<div
class="pt-3"
>
<a
class="pgn__hyperlink default-link standalone-link btn btn-primary btn-rounded font-weight-normal px-4 py-0625rem"
href="http://www.example.com/"
rel="noopener noreferrer"
target="_blank"
>
View Certificate
</a>
</div>
<p
class="mb-0 pt-3 small"
>
Credential ID
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>avatar</title>
<desc>Created with Sketch.</desc>
<g id="avatar" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z" fill="currentColor"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="110px" height="100px" viewBox="0 0 110 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
<title>micro-masters</title>
<desc>Created with Sketch.</desc>
<g id="micro-masters" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<polygon id="Fill-14" fill="#005585" points="1.00481933 99 1 96.14832 108.995272 96 109 98.85168"></polygon>
<polygon id="Fill-16" fill="#005585" points="1.00481933 54 1 51.14832 108.995272 51 109 53.85168"></polygon>
<polygon id="Fill-18" fill="#005585" points="24.9998189 88 4.22751315 66.9102771 4.22751315 87.0451102 1 87.0451102 1 59 24.9998189 83.3666894 49 59 49 87.0451102 45.7724869 87.0451102 45.7724869 66.9102771"></polygon>
<polygon id="Fill-20" fill="#005585" points="83.0003622 88 62.2275375 66.9102771 62.2275375 87.0451102 59 87.0451102 59 59 83.0003622 83.3666894 107 59 107 87.0451102 103.772372 87.0451102 103.772372 66.9102771"></polygon>
<path d="M46.8337141,5.73372796 C40.9959838,9.0009662 32.7599012,14.7887333 26.6032377,23.8303431 C22.1430738,22.980271 18.7577893,22.0941759 16.6724707,21.4890245 C24.4296997,13.0587208 35.0333668,7.38581116 46.8337141,5.73372796 L46.8337141,5.73372796 Z M52.9498983,26.3252576 C44.0609738,26.2888724 36.3049246,25.4607944 30.1183101,24.4518768 C37.8086485,13.8504137 48.4460785,8.26376038 52.9498983,6.25307565 L52.9498983,26.3252576 Z M56.1856092,6.53148511 C60.9824046,8.72165163 70.9689895,14.1856636 78.3079931,24.2371866 C70.4533778,25.5777336 63.0182586,26.1827945 56.1856092,26.3036256 L56.1856092,6.53148511 Z M91.2530147,21.4114572 C88.0536989,22.2655118 84.9019418,22.9909512 81.8101774,23.5971888 C75.749448,14.7672824 67.7047798,9.0930151 61.9038076,5.84261176 C73.3677956,7.61959908 83.6636931,13.2007313 91.2530147,21.4114572 L91.2530147,21.4114572 Z M5.35796556,42 C7.1655563,35.3542865 10.2683027,29.3264862 14.378947,24.1676747 C16.125002,24.7123653 19.7105047,25.7399279 24.7732291,26.7529184 C22.2125965,31.1374149 20.1687556,36.1942214 19.0530339,42 L22.3555447,42 C23.5090228,36.4059248 25.5817257,31.5545765 28.1556093,27.3837747 C34.7214222,28.5242036 43.1689773,29.5005374 52.9498983,29.5419006 L52.9498983,42 L56.1856092,42 L56.1856092,29.5223504 C63.6041191,29.395636 71.7140444,28.7101116 80.2902074,27.16121 C82.9053872,31.3760904 85.0043199,36.3025621 86.1572534,42 L89.460218,42 C88.3437702,36.0858806 86.2630805,30.9538601 83.6533464,26.5180443 C86.9031251,25.8529752 90.216981,25.0530458 93.5773971,24.1181656 C97.7090979,29.2882909 100.82809,35.3331976 102.641853,42 L106,42 C100.00834,18.4385583 78.6589648,2 53.9991832,2 C29.3299624,2 7.9913882,18.4352094 2,42 L5.35796556,42 Z" id="Fill-22" fill="#005585"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,83 +0,0 @@
import { AsyncActionType } from '../utils';
export const FETCH_PROFILE = new AsyncActionType('PROFILE', 'FETCH_PROFILE');
export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_PHOTO');
export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO');
// FETCH PROFILE ACTIONS
export const fetchProfile = username => ({
type: FETCH_PROFILE.BASE,
payload: { username },
});
export const fetchProfileBegin = () => ({
type: FETCH_PROFILE.BEGIN,
});
export const fetchProfileSuccess = (
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
) => ({
type: FETCH_PROFILE.SUCCESS,
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
});
export const fetchProfileReset = () => ({
type: FETCH_PROFILE.RESET,
});
// SAVE PROFILE PHOTO ACTIONS
export const saveProfilePhoto = (username, formData) => ({
type: SAVE_PROFILE_PHOTO.BASE,
payload: {
username,
formData,
},
});
export const saveProfilePhotoBegin = () => ({
type: SAVE_PROFILE_PHOTO.BEGIN,
});
export const saveProfilePhotoSuccess = profileImage => ({
type: SAVE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage },
});
export const saveProfilePhotoReset = () => ({
type: SAVE_PROFILE_PHOTO.RESET,
});
export const saveProfilePhotoFailure = error => ({
type: SAVE_PROFILE_PHOTO.FAILURE,
payload: { error },
});
// DELETE PROFILE PHOTO ACTIONS
export const deleteProfilePhoto = username => ({
type: DELETE_PROFILE_PHOTO.BASE,
payload: {
username,
},
});
export const deleteProfilePhotoBegin = () => ({
type: DELETE_PROFILE_PHOTO.BEGIN,
});
export const deleteProfilePhotoSuccess = profileImage => ({
type: DELETE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage },
});
export const deleteProfilePhotoReset = () => ({
type: DELETE_PROFILE_PHOTO.RESET,
});

View File

@@ -1,98 +0,0 @@
import {
SAVE_PROFILE_PHOTO,
saveProfilePhotoBegin,
saveProfilePhotoSuccess,
saveProfilePhotoFailure,
saveProfilePhotoReset,
saveProfilePhoto,
DELETE_PROFILE_PHOTO,
deleteProfilePhotoBegin,
deleteProfilePhotoSuccess,
deleteProfilePhotoReset,
deleteProfilePhoto,
} from './actions';
describe('SAVE profile photo actions', () => {
it('should create an action to signal the start of a profile photo save', () => {
const formData = 'multipart form data';
const expectedAction = {
type: SAVE_PROFILE_PHOTO.BASE,
payload: {
username: 'myusername',
formData,
},
};
expect(saveProfilePhoto('myusername', formData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save beginning', () => {
const expectedAction = {
type: SAVE_PROFILE_PHOTO.BEGIN,
};
expect(saveProfilePhotoBegin()).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save success', () => {
const newPhotoData = { hasImage: true };
const expectedAction = {
type: SAVE_PROFILE_PHOTO.SUCCESS,
payload: {
profileImage: newPhotoData,
},
};
expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save reset', () => {
const expectedAction = {
type: SAVE_PROFILE_PHOTO.RESET,
};
expect(saveProfilePhotoReset()).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save failure', () => {
const error = 'Test failure';
const expectedAction = {
type: SAVE_PROFILE_PHOTO.FAILURE,
payload: { error },
};
expect(saveProfilePhotoFailure(error)).toEqual(expectedAction);
});
});
describe('DELETE profile photo actions', () => {
it('should create an action to signal the start of a profile photo deletion', () => {
const expectedAction = {
type: DELETE_PROFILE_PHOTO.BASE,
payload: {
username: 'myusername',
},
};
expect(deleteProfilePhoto('myusername')).toEqual(expectedAction);
});
it('should create an action to signal user profile photo deletion beginning', () => {
const expectedAction = {
type: DELETE_PROFILE_PHOTO.BEGIN,
};
expect(deleteProfilePhotoBegin()).toEqual(expectedAction);
});
it('should create an action to signal user profile photo deletion success', () => {
const defaultPhotoData = { hasImage: false };
const expectedAction = {
type: DELETE_PROFILE_PHOTO.SUCCESS,
payload: {
profileImage: defaultPhotoData,
},
};
expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo deletion reset', () => {
const expectedAction = {
type: DELETE_PROFILE_PHOTO.RESET,
};
expect(deleteProfilePhotoReset()).toEqual(expectedAction);
});
});

View File

@@ -1,28 +0,0 @@
const EDUCATION_LEVELS = [
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'other',
];
const SOCIAL = {
linkedin: {
title: 'LinkedIn',
},
twitter: {
title: 'Twitter',
},
facebook: {
title: 'Facebook',
},
};
export {
EDUCATION_LEVELS,
SOCIAL,
};

View File

@@ -1,11 +0,0 @@
import { breakpoints, useWindowSize } from '@openedx/paragon';
export function useIsOnTabletScreen() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.medium.minWidth;
}
export function useIsOnMobileScreen() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.small.minWidth;
}

View File

@@ -1,7 +0,0 @@
const mockData = {
learningGoal: 'advance_career',
editMode: 'static',
visibilityLearningGoal: 'private',
};
export default mockData;

View File

@@ -1,80 +0,0 @@
// This test file simply creates a contract that defines
// expectations and correct responses from the Pact stub server.
import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { initializeMockApp, getConfig, setConfig } from '@edx/frontend-platform';
import { getAccount } from './services';
const expectedUserInfo200 = {
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
dateJoined: '2017-06-07T00:44:23Z',
isActive: true,
yearOfBirth: 1901,
};
const provider = new PactV3({
log: path.resolve(process.cwd(), 'src/pact-logs/pact.log'),
dir: path.resolve(process.cwd(), 'src/pacts'),
consumer: 'frontend-app-profile',
provider: 'edx-platform',
});
describe('getAccount for one username', () => {
beforeAll(async () => {
initializeMockApp();
});
it('returns a HTTP 200 and user information', async () => {
const username200 = 'staff';
await provider.addInteraction({
states: [{ description: "I have a user's basic information" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username200}`,
headers: {},
},
willRespondWith: {
status: 200,
headers: {},
body: MatchersV3.like(expectedUserInfo200),
},
});
return provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
const response = await getAccount(username200);
expect(response).toEqual(expectedUserInfo200);
});
});
it('Account does not exist', async () => {
const username404 = 'staff_not_found';
await provider.addInteraction({
states: [{ description: "Account and user's information does not exist" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username404}`,
},
willRespondWith: {
status: 404,
},
});
await provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
await expect(getAccount(username404).then((response) => response.data)).rejects.toThrow('Request failed with status code 404');
});
});
});

View File

@@ -1,99 +0,0 @@
import {
SAVE_PROFILE_PHOTO,
DELETE_PROFILE_PHOTO,
FETCH_PROFILE,
} from './actions';
export const initialState = {
errors: {},
savePhotoState: null,
account: {
socialLinks: [],
},
preferences: {},
courseCertificates: [],
isLoadingProfile: true,
isAuthenticatedUserProfile: false,
disabledCountries: ['RU'],
};
const profilePage = (state = initialState, action = {}) => {
switch (action.type) {
case FETCH_PROFILE.BEGIN:
return {
...state,
// TODO: uncomment this line after ARCH-438 Image Post API returns the url
// is complete. Right now we refetch the whole profile causing us to show a full reload
// instead of a partial one.
// isLoadingProfile: true,
};
case FETCH_PROFILE.SUCCESS:
return {
...state,
account: action.account,
preferences: action.preferences,
courseCertificates: action.courseCertificates,
isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
};
case SAVE_PROFILE_PHOTO.BEGIN:
return {
...state,
savePhotoState: 'pending',
errors: {},
};
case SAVE_PROFILE_PHOTO.SUCCESS:
return {
...state,
// Merge in new profile image data
account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
case SAVE_PROFILE_PHOTO.FAILURE:
return {
...state,
savePhotoState: 'error',
errors: { ...state.errors, photo: action.payload.error },
};
case SAVE_PROFILE_PHOTO.RESET:
return {
...state,
savePhotoState: null,
errors: {},
};
case DELETE_PROFILE_PHOTO.BEGIN:
return {
...state,
savePhotoState: 'pending',
errors: {},
};
case DELETE_PROFILE_PHOTO.SUCCESS:
return {
...state,
// Merge in new profile image data (should be empty or default image)
account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
case DELETE_PROFILE_PHOTO.FAILURE:
return {
...state,
savePhotoState: 'error',
errors: { ...state.errors, ...action.payload.errors },
};
case DELETE_PROFILE_PHOTO.RESET:
return {
...state,
savePhotoState: null,
errors: {},
};
default:
return state;
}
};
export default profilePage;

View File

@@ -1,140 +0,0 @@
import profilePage, { initialState } from './reducers';
import {
SAVE_PROFILE_PHOTO,
DELETE_PROFILE_PHOTO,
FETCH_PROFILE,
} from './actions';
describe('profilePage reducer', () => {
it('should return the initial state by default', () => {
expect(profilePage(undefined, {})).toEqual(initialState);
});
describe('FETCH_PROFILE actions', () => {
it('should handle FETCH_PROFILE.BEGIN', () => {
const action = { type: FETCH_PROFILE.BEGIN };
const expectedState = {
...initialState,
// Uncomment isLoadingProfile: true if this functionality is required.
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle FETCH_PROFILE.SUCCESS', () => {
const action = {
type: FETCH_PROFILE.SUCCESS,
account: { name: 'John Doe' },
preferences: { theme: 'dark' },
courseCertificates: ['cert1', 'cert2'],
isAuthenticatedUserProfile: true,
};
const expectedState = {
...initialState,
account: action.account,
preferences: action.preferences,
courseCertificates: action.courseCertificates,
isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('SAVE_PROFILE_PHOTO actions', () => {
it('should handle SAVE_PROFILE_PHOTO.BEGIN', () => {
const action = { type: SAVE_PROFILE_PHOTO.BEGIN };
const expectedState = {
...initialState,
savePhotoState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.SUCCESS', () => {
const action = {
type: SAVE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage: 'new-image-url.jpg' },
};
const expectedState = {
...initialState,
account: { ...initialState.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.FAILURE', () => {
const action = {
type: SAVE_PROFILE_PHOTO.FAILURE,
payload: { error: 'Photo upload failed' },
};
const expectedState = {
...initialState,
savePhotoState: 'error',
errors: { photo: action.payload.error },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.RESET', () => {
const action = { type: SAVE_PROFILE_PHOTO.RESET };
const expectedState = {
...initialState,
savePhotoState: null,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('DELETE_PROFILE_PHOTO actions', () => {
it('should handle DELETE_PROFILE_PHOTO.BEGIN', () => {
const action = { type: DELETE_PROFILE_PHOTO.BEGIN };
const expectedState = {
...initialState,
savePhotoState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.SUCCESS', () => {
const action = {
type: DELETE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage: 'default-image-url.jpg' },
};
const expectedState = {
...initialState,
account: { ...initialState.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.FAILURE', () => {
const action = {
type: DELETE_PROFILE_PHOTO.FAILURE,
payload: { errors: { delete: 'Failed to delete photo' } },
};
const expectedState = {
...initialState,
savePhotoState: 'error',
errors: { ...initialState.errors, delete: action.payload.errors.delete },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.RESET', () => {
const action = { type: DELETE_PROFILE_PHOTO.RESET };
const expectedState = {
...initialState,
savePhotoState: null,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
});

View File

@@ -1,127 +0,0 @@
import { history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import {
all,
call,
put,
select,
takeEvery,
} from 'redux-saga/effects';
import {
deleteProfilePhotoBegin,
deleteProfilePhotoReset,
deleteProfilePhotoSuccess,
DELETE_PROFILE_PHOTO,
fetchProfileBegin,
fetchProfileReset,
fetchProfileSuccess,
FETCH_PROFILE,
saveProfilePhotoBegin,
saveProfilePhotoReset,
saveProfilePhotoSuccess,
SAVE_PROFILE_PHOTO,
} from './actions';
import { userAccountSelector } from './selectors';
import * as ProfileApiService from './services';
export function* handleFetchProfile(action) {
const { username } = action.payload;
const userAccount = yield select(userAccountSelector);
const isAuthenticatedUserProfile = username === getAuthenticatedUser().username;
// Default our data assuming the account is the current user's account.
let preferences = {};
let account = userAccount;
let courseCertificates = null;
try {
yield put(fetchProfileBegin());
// Depending on which profile we're loading, we need to make different calls.
const calls = [
call(ProfileApiService.getAccount, username),
call(ProfileApiService.getCourseCertificates, username),
];
if (isAuthenticatedUserProfile) {
// If the profile is for the current user, get their preferences.
// We don't need them for other users.
calls.push(call(ProfileApiService.getPreferences, username));
}
// Make all the calls in parallel.
const result = yield all(calls);
if (isAuthenticatedUserProfile) {
[account, courseCertificates, preferences] = result;
} else {
[account, courseCertificates] = result;
}
// Set initial visibility values for account
// Set account_privacy as custom is necessary so that when viewing another user's profile,
// their full name is displayed and change visibility forms are worked correctly
if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') {
yield call(ProfileApiService.patchPreferences, action.payload.username, {
account_privacy: 'custom',
'visibility.name': 'all_users',
'visibility.bio': 'all_users',
'visibility.course_certificates': 'all_users',
'visibility.country': 'all_users',
'visibility.date_joined': 'all_users',
'visibility.level_of_education': 'all_users',
'visibility.language_proficiencies': 'all_users',
'visibility.social_links': 'all_users',
'visibility.time_zone': 'all_users',
});
}
yield put(fetchProfileSuccess(
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
));
yield put(fetchProfileReset());
} catch (e) {
if (e.response.status === 404) {
history.push('/notfound');
} else {
throw e;
}
}
}
export function* handleSaveProfilePhoto(action) {
const { username, formData } = action.payload;
try {
yield put(saveProfilePhotoBegin());
const photoResult = yield call(ProfileApiService.postProfilePhoto, username, formData);
yield put(saveProfilePhotoSuccess(photoResult));
yield put(saveProfilePhotoReset());
} catch (e) {
// Just reset on error, since editing functionality is deprecated
yield put(saveProfilePhotoReset());
}
}
export function* handleDeleteProfilePhoto(action) {
const { username } = action.payload;
try {
yield put(deleteProfilePhotoBegin());
const photoResult = yield call(ProfileApiService.deleteProfilePhoto, username);
yield put(deleteProfilePhotoSuccess(photoResult));
yield put(deleteProfilePhotoReset());
} catch (e) {
// Just reset on error, since editing functionality is deprecated
yield put(deleteProfilePhotoReset());
}
}
export default function* profileSaga() {
yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile);
yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto);
yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto);
}

View File

@@ -1,159 +0,0 @@
import {
takeEvery,
put,
call,
select,
all,
} from 'redux-saga/effects';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import * as profileActions from './actions';
import { userAccountSelector } from './selectors';
import profileSaga, {
handleFetchProfile,
handleSaveProfilePhoto,
handleDeleteProfilePhoto,
} from './sagas';
import * as ProfileApiService from './services';
import {
deleteProfilePhotoBegin,
deleteProfilePhotoReset,
saveProfilePhotoBegin,
saveProfilePhotoReset,
} from './actions';
jest.mock('./services', () => ({
getAccount: jest.fn(),
getCourseCertificates: jest.fn(),
getPreferences: jest.fn(),
postProfilePhoto: jest.fn(),
deleteProfilePhoto: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
}));
describe('RootSaga', () => {
describe('profileSaga', () => {
it('should pass actions to the correct sagas', () => {
const gen = profileSaga();
expect(gen.next().value)
.toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto));
expect(gen.next().value).toBeUndefined();
});
});
describe('handleFetchProfile', () => {
it('should fetch certificates and preferences for the current user profile', () => {
const userAccount = {
username: 'gonzo',
other: 'data',
};
getAuthenticatedUser.mockReturnValue(userAccount);
const selectorData = {
userAccount,
};
const action = profileActions.fetchProfile('gonzo');
const gen = handleFetchProfile(action);
const result = [userAccount, [1, 2, 3], { preferences: 'stuff' }];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'gonzo'),
call(ProfileApiService.getCourseCertificates, 'gonzo'),
call(ProfileApiService.getPreferences, 'gonzo'),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[2], result[1], true)));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});
it('should fetch certificates and profile for some other user profile', () => {
const userAccount = {
username: 'gonzo',
other: 'data',
};
getAuthenticatedUser.mockReturnValue(userAccount);
const selectorData = {
userAccount,
};
const action = profileActions.fetchProfile('booyah');
const gen = handleFetchProfile(action);
const result = [{}, [1, 2, 3]];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'booyah'),
call(ProfileApiService.getCourseCertificates, 'booyah'),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false)));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});
});
describe('handleSaveProfilePhoto', () => {
it('should publish a reset action on error', () => {
const action = profileActions.saveProfilePhoto('my username', {});
const gen = handleSaveProfilePhoto(action);
const error = new Error('Error occurred');
expect(gen.next().value).toEqual(put(saveProfilePhotoBegin()));
expect(gen.throw(error).value).toEqual(put(saveProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
});
describe('handleDeleteProfilePhoto', () => {
it('should publish a reset action on error', () => {
const action = profileActions.deleteProfilePhoto('my username');
const gen = handleDeleteProfilePhoto(action);
const error = new Error('Error occurred');
expect(gen.next().value).toEqual(put(deleteProfilePhotoBegin()));
expect(gen.throw(error).value).toEqual(put(deleteProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
});
describe('handleDeleteProfilePhoto', () => {
it('should successfully process a deleteProfilePhoto request if there are no exceptions', () => {
const action = profileActions.deleteProfilePhoto('my username');
const gen = handleDeleteProfilePhoto(action);
const photoResult = {};
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.deleteProfilePhoto, 'my username'));
expect(gen.next(photoResult).value).toEqual(put(profileActions.deleteProfilePhotoSuccess(photoResult)));
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
it('should publish a failure action on exception', () => {
const error = new Error('Error occurred');
const action = profileActions.deleteProfilePhoto('my username');
const gen = handleDeleteProfilePhoto(action);
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin()));
const result = gen.throw(error);
expect(result.value).toEqual(put(profileActions.deleteProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
});
});

View File

@@ -1,58 +0,0 @@
import { createSelector } from 'reselect';
export const userAccountSelector = state => state.userAccount;
export const profileAccountSelector = state => state.profilePage.account;
export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates;
export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile;
export const accountErrorsSelector = state => state.profilePage.errors;
export const certificatesSelector = createSelector(
profileCourseCertificatesSelector,
(certificates) => ({
certificates,
value: certificates,
}),
);
export const profileImageSelector = createSelector(
profileAccountSelector,
account => (account.profileImage != null
? {
src: account.profileImage.imageUrlFull,
isDefault: !account.profileImage.hasImage,
}
: {}),
);
export const profilePageSelector = createSelector(
profileAccountSelector,
profileCourseCertificatesSelector,
profileImageSelector,
savePhotoStateSelector,
isLoadingProfileSelector,
accountErrorsSelector,
(
account,
courseCertificates,
profileImage,
savePhotoState,
isLoadingProfile,
errors,
) => ({
// Account data we need
username: account.username,
profileImage,
requiresParentalConsent: account.requiresParentalConsent,
dateJoined: account.dateJoined,
yearOfBirth: account.yearOfBirth,
name: account.name,
courseCertificates,
// Other data we need
savePhotoState,
isLoadingProfile,
photoUploadError: errors.photo || null,
}),
);

View File

@@ -1,149 +0,0 @@
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils';
ensureConfig(['LMS_BASE_URL'], 'Profile API service');
function processAccountData(data) {
return camelCaseObject(data);
}
function processAndThrowError(error, errorDataProcessor) {
const processedError = Object.create(error);
if (error.response && error.response.data && typeof error.response.data === 'object') {
processedError.processedData = errorDataProcessor(error.response.data);
throw processedError;
} else {
throw error;
}
}
// GET ACCOUNT
export async function getAccount(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
// Process response data
return processAccountData(data);
}
// PATCH PROFILE
export async function patchProfile(username, params) {
const processedParams = snakeCaseObject(params);
const { data } = await getHttpClient()
.patch(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`, processedParams, {
headers: {
'Content-Type': 'application/merge-patch+json',
},
})
.catch((error) => {
processAndThrowError(error, processAccountData);
});
// Process response data
return processAccountData(data);
}
// POST PROFILE PHOTO
export async function postProfilePhoto(username, formData) {
// eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().post(
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
).catch((error) => {
processAndThrowError(error, camelCaseObject);
});
// TODO: Someday in the future the POST photo endpoint
// will return the new values. At that time we should
// use the commented line below instead of the separate
// getAccount request that follows.
// return camelCaseObject(data);
const updatedData = await getAccount(username);
return updatedData.profileImage;
}
// DELETE PROFILE PHOTO
export async function deleteProfilePhoto(username) {
// eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().delete(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`);
// TODO: Someday in the future the POST photo endpoint
// will return the new values. At that time we should
// use the commented line below instead of the separate
// getAccount request that follows.
// return camelCaseObject(data);
const updatedData = await getAccount(username);
return updatedData.profileImage;
}
// GET PREFERENCES
export async function getPreferences(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
return camelCaseObject(data);
}
// PATCH PREFERENCES
export async function patchPreferences(username, params) {
let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, {
visibility_bio: 'visibility.bio',
visibility_course_certificates: 'visibility.course_certificates',
visibility_country: 'visibility.country',
visibility_date_joined: 'visibility.date_joined',
visibility_level_of_education: 'visibility.level_of_education',
visibility_language_proficiencies: 'visibility.language_proficiencies',
visibility_name: 'visibility.name',
visibility_social_links: 'visibility.social_links',
visibility_time_zone: 'visibility.time_zone',
});
await getHttpClient().patch(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`, processedParams, {
headers: { 'Content-Type': 'application/merge-patch+json' },
});
return params; // TODO: Once the server returns the updated preferences object, return that.
}
// GET COURSE CERTIFICATES
function transformCertificateData(data) {
const transformedData = [];
data.forEach((cert) => {
// download_url may be full url or absolute path.
// note: using the URL() api breaks in ie 11
const urlIsPath = typeof cert.download_url === 'string'
&& cert.download_url.search(/http[s]?:\/\//) !== 0;
const downloadUrl = urlIsPath
? `${getConfig().LMS_BASE_URL}${cert.download_url}`
: cert.download_url;
transformedData.push({
...camelCaseObject(cert),
certificateType: cert.certificate_type,
downloadUrl,
});
});
return transformedData;
}
export async function getCourseCertificates(username) {
const url = `${getConfig().LMS_BASE_URL}/api/certificates/v0/certificates/${username}/`;
try {
const { data } = await getHttpClient().get(url);
return transformCertificateData(data);
} catch (e) {
logError(e);
return [];
}
}

View File

@@ -1,159 +0,0 @@
import React, { useRef } from 'react';
import PropTypes from 'prop-types';
import { Button, Dropdown } from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ReactComponent as DefaultAvatar } from '../assets/avatar.svg';
import messages from './ProfileAvatar.messages';
const ProfileAvatar = ({
src,
isDefault,
onSave,
onDelete,
savePhotoState,
isEditable,
}) => {
const intl = useIntl();
const fileInput = useRef(null);
const form = useRef(null);
const onClickUpload = () => {
fileInput.current.click();
};
const onClickDelete = () => {
onDelete();
};
const onSubmit = (e) => {
if (e) {
e.preventDefault();
}
onSave(new FormData(form.current));
form.current.reset();
};
const onChangeInput = () => {
onSubmit();
};
const renderPending = () => (
<div
className="position-absolute w-100 h-100 d-flex justify-content-center
align-items-center rounded-circle background-black-65"
>
<div className="spinner-border text-primary" role="status" />
</div>
);
const renderMenuContent = () => {
if (isDefault) {
return (
<Button
variant="link"
size="sm"
className="text-white btn-block"
onClick={onClickUpload}
>
<FormattedMessage
id="profile.profileavatar.upload-button"
defaultMessage="Upload Photo"
description="Upload photo button"
/>
</Button>
);
}
return (
<Dropdown>
<Dropdown.Toggle>
{intl.formatMessage(messages['profile.profileavatar.change-button'])}
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item type="button" onClick={onClickUpload}>
<FormattedMessage
id="profile.profileavatar.upload-button"
defaultMessage="Upload Photo"
description="Upload photo button"
/>
</Dropdown.Item>
<Dropdown.Item type="button" onClick={onClickDelete}>
<FormattedMessage
id="profile.profileavatar.remove.button"
defaultMessage="Remove"
description="Remove photo button"
/>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
};
const renderMenu = () => {
if (!isEditable) {
return null;
}
return (
<div className="profile-avatar-menu-container">
{renderMenuContent()}
</div>
);
};
const renderAvatar = () => (
isDefault ? (
<DefaultAvatar className="text-muted" role="img" aria-hidden focusable="false" viewBox="0 0 24 24" />
) : (
<img
data-hj-suppress
className="w-100 h-100 d-block rounded-circle overflow-hidden object-fit-cover"
alt={intl.formatMessage(messages['profile.image.alt.attribute'])}
src={src}
/>
)
);
return (
<div className="profile-avatar-wrap position-relative">
<div className="profile-avatar rounded-circle bg-light">
{savePhotoState === 'pending' ? renderPending() : renderMenu()}
{renderAvatar()}
</div>
<form
ref={form}
onSubmit={onSubmit}
encType="multipart/form-data"
>
{/* The name of this input must be 'file' */}
<input
className="d-none form-control-file"
ref={fileInput}
type="file"
name="file"
id="photo-file"
onChange={onChangeInput}
accept=".jpg, .jpeg, .png"
/>
</form>
</div>
);
};
ProfileAvatar.propTypes = {
src: PropTypes.string,
isDefault: PropTypes.bool,
onSave: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
isEditable: PropTypes.bool,
};
ProfileAvatar.defaultProps = {
src: null,
isDefault: true,
savePhotoState: null,
isEditable: false,
};
export default ProfileAvatar;

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.image.alt.attribute': {
id: 'profile.image.alt.attribute',
defaultMessage: 'profile avatar',
description: 'Alt attribute for a profile photo',
},
'profile.profileavatar.change-button': {
id: 'profile.profileavatar.change-button',
defaultMessage: 'Change',
description: 'Change photo button',
},
});
export default messages;

View File

@@ -1,5 +0,0 @@
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { default as ProfilePage } from './ProfilePage';
export { default as NotFoundPage } from './NotFoundPage';
export { default as messages } from './ProfilePage.messages';

View File

@@ -1,231 +0,0 @@
.word-break-all {
word-break: break-all !important;
}
// TODO: Update edx-bootstrap theme to incorporate these edits.
.btn, a.btn {
text-decoration: none;
&:hover {
text-decoration: none;
}
}
.btn-link {
text-decoration: underline;
&:hover {
text-decoration: underline;
}
}
.profile-page-bg-banner {
height: 298px;
width: 100%;
background-image: url('./assets/dot-pattern-light.png');
background-repeat: repeat-x;
background-size: auto 85%;
}
.icon-visibility-off {
height: 1rem;
color: $gray-500;
}
.profile-page {
.edit-section-header {
@extend .h6;
display: block;
font-weight: normal;
letter-spacing: 0;
margin: 0;
}
label.edit-section-header {
margin-bottom: $spacer * .5;
}
.profile-avatar-wrap {
@include media-breakpoint-up(md) {
max-width: 12rem;
margin-right: 0;
height: auto;
}
}
.profile-avatar-menu-container {
background: rgba(0,0,0,.65);
position: absolute;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
@include media-breakpoint-up(md) {
background: linear-gradient(to top, rgba(0,0,0,.65) 4rem, rgba(0,0,0,0) 4rem);
align-items: flex-end;
}
.btn {
text-decoration: none;
@include media-breakpoint-up(md) {
margin-bottom: 1.2rem;
}
}
.dropdown {
@include media-breakpoint-up(md) {
margin-bottom: 1.2rem;
}
.btn {
color: $white;
background: transparent;
border-color: transparent;
margin: 0;
}
}
}
.profile-avatar {
width: 5rem;
height: 5rem;
position: relative;
@include media-breakpoint-up(md) {
width: 7.5rem;
height: 7.5rem;
}
.profile-avatar-edit-button {
border: none;
position: absolute;
height: 100%;
left: 0;
width: 100%;
bottom: 0;
display: flex;
justify-content: center;
padding-top: .1rem;
font-weight: 600;
background: rgba(0,0,0,.5);
border-radius:0;
transition: opacity 200ms ease;
@include media-breakpoint-up(md) {
height: 4rem;
}
&:focus, &:hover, &:active, &.active {
opacity: 1;
}
}
}
.certificate {
background-color: #F3F1ED;
border-radius: 0.75rem;
overflow: hidden;
border: 1px #E7E4DB solid;
.certificate-type-illustration {
position: absolute;
top: 1rem;
right: 1rem;
bottom: 0;
width: 15.15rem;
opacity: .06;
background-size: 90%;
background-repeat: no-repeat;
background-position: right top;
}
}
}
// Todo: Move the following to edx-paragon
.btn-rounded {
border-radius: 100px;
}
.max-width-32em {
max-width: 32em;
}
.max-width-19rem{
max-width: 19rem;
}
.width-75rem {
width: 75rem;
}
.width-72rem {
width: 72rem !important;
}
.width-19625rem {
width: 19.625rem;
}
.height-2625rem {
height: 2.625rem;
}
.height-50vh {
height: 50vh;
}
.rounded-75 {
border-radius: 0.75rem;
}
.pt-4rem {
padding-top: 4rem;
}
.py-4rem {
padding-top: 4rem;
padding-bottom: 4rem;
}
.py-0625rem {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
.px-75rem {
padding-left: 7.5rem;
padding-right: 7.5rem;
}
.px-25rem {
padding-left: 2.5rem;
padding-right: 2.5rem;
}
.g-15rem {
gap: 1.5rem;
}
.g-5rem {
gap: 0.5rem;
}
.g-1rem {
gap: 1rem;
}
.g-3rem {
gap: 3rem;
}
.color-black {
color: #000;
}
.background-black-65 {
background-color: rgba(0,0,0,.65)
}
.object-fit-cover {
object-fit: cover;
}

View File

@@ -1,71 +0,0 @@
import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase';
export function modifyObjectKeys(object, modify) {
// If the passed in object is not an object, return it.
if (
object === undefined
|| object === null
|| (typeof object !== 'object' && !Array.isArray(object))
) {
return object;
}
if (Array.isArray(object)) {
return object.map(value => modifyObjectKeys(value, modify));
}
// Otherwise, process all its keys.
const result = {};
Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify);
});
return result;
}
export function camelCaseObject(object) {
return modifyObjectKeys(object, camelCase);
}
export function snakeCaseObject(object) {
return modifyObjectKeys(object, snakeCase);
}
export function convertKeyNames(object, nameMap) {
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
return modifyObjectKeys(object, transformer);
}
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*
* TODO: Put somewhere common to it can be used by other MFEs.
*/
export class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
}

View File

@@ -1,103 +0,0 @@
import {
AsyncActionType,
modifyObjectKeys,
camelCaseObject,
snakeCaseObject,
convertKeyNames,
} from './utils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
function meowKeys(key) {
return `${key}Meow`;
}
const result = modifyObjectKeys(
{
one: undefined,
two: null,
three: '',
four: 0,
five: NaN,
six: [1, 2, { seven: 'woof' }],
eight: { nine: { ten: 'bark' }, eleven: true },
},
meowKeys,
);
expect(result).toEqual({
oneMeow: undefined,
twoMeow: null,
threeMeow: '',
fourMeow: 0,
fiveMeow: NaN,
sixMeow: [1, 2, { sevenMeow: 'woof' }],
eightMeow: { nineMeow: { tenMeow: 'bark' }, elevenMeow: true },
});
});
});
describe('camelCaseObject', () => {
it('should make everything camelCase', () => {
const result = camelCaseObject({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
dotDotDot: 123,
});
});
});
describe('snakeCaseObject', () => {
it('should make everything snake_case', () => {
const result = snakeCaseObject({
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
dot_dot_dot: 123,
});
});
});
describe('convertKeyNames', () => {
it('should replace the specified keynames', () => {
const result = convertKeyNames(
{
one: { two: { three: 'four' } },
five: 'six',
},
{
two: 'blue',
five: 'alive',
seven: 'heaven',
},
);
expect(result).toEqual({
one: { blue: { three: 'four' } },
alive: 'six',
});
});
});
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
});
});

View File

@@ -1,43 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
const AgeMessage = ({ accountSettingsUrl }) => (
<Alert
variant="info"
dismissible={false}
show
>
<Alert.Heading id="profile.age.headline">
<FormattedMessage
id="profile.age.cannotShare"
defaultMessage="Your profile cannot be shared."
description="Error message indicating that the user's profile cannot be shared"
/>
</Alert.Heading>
<FormattedMessage
id="profile.age.details"
defaultMessage="To share your profile with other {siteName} learners, you must confirm that you are over the age of 13."
description="Error message"
tagName="p"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
<Alert.Link href={accountSettingsUrl}>
<FormattedMessage
id="profile.age.set.date"
defaultMessage="Set your date of birth"
description="Label on a link to set birthday"
/>
</Alert.Link>
</Alert>
);
AgeMessage.propTypes = {
accountSettingsUrl: PropTypes.string.isRequired,
};
export default AgeMessage;

View File

@@ -1,5 +0,0 @@
import React from 'react';
const Banner = () => <div className="profile-page-bg-banner bg-primary d-md-block p-relative" />;
export default Banner;

View File

@@ -43,8 +43,8 @@ const CertificateCard = ({
/>
<div className={classNames(
'd-flex flex-column position-relative p-0',
{ 'max-width-19rem': isMobileView },
{ 'width-19625rem': !isMobileView },
{ 'max-width-304px': isMobileView },
{ 'width-314px': !isMobileView },
)}
>
<div className="w-100 color-black">
@@ -97,7 +97,7 @@ const CertificateCard = ({
target="_blank"
showLaunchIcon={false}
className={classNames(
'btn btn-primary btn-rounded font-weight-normal px-4 py-0625rem',
'btn btn-primary font-weight-normal px-4 py-10px',
{ 'btn-sm': isMobileView },
)}
>

View File

@@ -7,20 +7,15 @@ import { getConfig } from '@edx/frontend-platform';
import classNames from 'classnames';
import CertificateCard from './CertificateCard';
import { certificatesSelector } from './data/selectors';
import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks';
import { useIsOnTabletScreen } from './data/hooks';
const Certificates = ({ certificates }) => {
const isMobileView = useIsOnMobileScreen();
const isTabletView = useIsOnTabletScreen();
return (
<div>
<div className="col justify-content-start align-items-start g-5rem p-0">
<div className="col align-self-stretch height-2625rem justify-content-start align-items-start p-0">
<p className={classNames([
'font-weight-bold text-primary-500 m-0',
isMobileView ? 'h3' : 'h2',
])}
>
<div className="col align-self-stretch height-42px justify-content-start align-items-start p-0">
<p className="font-weight-bold text-primary-500 m-0 h2">
<FormattedMessage
id="profile.your.certificates"
defaultMessage="Your certificates"
@@ -29,14 +24,10 @@ const Certificates = ({ certificates }) => {
</p>
</div>
<div className="col justify-content-start align-items-start pt-2 p-0">
<p className={classNames([
'font-weight-normal text-gray-800 m-0 p-0',
isMobileView ? 'h5' : 'p',
])}
>
<p className="font-weight-normal text-gray-800 m-0 p-0 p">
<FormattedMessage
id="profile.certificates.description"
defaultMessage="Your learner records information is only visible to you. Only your username is visible to others on {siteName}."
defaultMessage="Your learner records information is only visible to you. Only your username and profile image are visible to others on {siteName}."
description="description of the certificates section"
values={{
siteName: getConfig().SITE_NAME,

View File

@@ -1,23 +1,21 @@
import React from 'react';
import React, { memo } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
const DateJoined = ({ date }) => {
if (date == null) {
return null;
}
if (!date) { return null; }
return (
<p className="mb-0">
<span className="small mb-0 text-gray-800">
<FormattedMessage
id="profile.datejoined.member.since"
defaultMessage="Member since {year}"
description="A label for how long the user has been a member"
values={{
year: <FormattedDate value={new Date(date)} year="numeric" />,
year: <span className="font-weight-bold"> <FormattedDate value={new Date(date)} year="numeric" /> </span>,
}}
/>
</p>
</span>
);
};
@@ -28,4 +26,4 @@ DateJoined.defaultProps = {
date: null,
};
export default DateJoined;
export default memo(DateJoined);

View File

@@ -3,7 +3,7 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
<p className="my-0 py-5 text-muted max-width-32em">
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."

View File

@@ -1,37 +1,18 @@
import React, { Component } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
export default class PageLoading extends Component {
renderSrMessage() {
if (!this.props.srMessage) {
return null;
}
return (
<span className="sr-only">
{this.props.srMessage}
</span>
);
}
render() {
return (
<div>
<div
className="d-flex justify-content-center align-items-center flex-column"
style={{
height: '50vh',
}}
>
<div className="spinner-border text-primary" role="status">
{this.renderSrMessage()}
</div>
</div>
const PageLoading = ({ srMessage }) => (
<div>
<div className="d-flex justify-content-center align-items-center flex-column height-50vh">
<div className="spinner-border text-primary" role="status">
{srMessage && <span className="sr-only">{srMessage}</span>}
</div>
);
}
}
</div>
</div>
);
PageLoading.propTypes = {
srMessage: PropTypes.string.isRequired,
};
export default PageLoading;

View File

@@ -1,14 +1,20 @@
import React from 'react';
import React, {
useEffect, useState, useContext, useCallback,
} from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { ensureConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Alert, Hyperlink, OverlayTrigger, Tooltip,
} from '@openedx/paragon';
import { InfoOutline } from '@openedx/paragon/icons';
import classNames from 'classnames';
// Actions
import {
fetchProfile,
saveProfile,
@@ -19,7 +25,6 @@ import {
updateDraft,
} from './data/actions';
// Components
import ProfileAvatar from './forms/ProfileAvatar';
import Name from './forms/Name';
import Country from './forms/Country';
@@ -27,120 +32,124 @@ import PreferredLanguage from './forms/PreferredLanguage';
import Education from './forms/Education';
import SocialLinks from './forms/SocialLinks';
import Bio from './forms/Bio';
import Certificates from './forms/Certificates';
import AgeMessage from './AgeMessage';
import DateJoined from './DateJoined';
import UsernameDescription from './UsernameDescription';
import UserCertificateSummary from './UserCertificateSummary';
import PageLoading from './PageLoading';
import Banner from './Banner';
import LearningGoal from './forms/LearningGoal';
import Certificates from './Certificates';
// Selectors
import { profilePageSelector } from './data/selectors';
// i18n
import messages from './ProfilePage.messages';
import withParams from '../utils/hoc';
import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
class ProfilePage extends React.Component {
constructor(props, context) {
super(props, context);
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL', 'ACCOUNT_SETTINGS_URL'], 'ProfilePage');
const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL;
this.state = {
viewMyRecordsUrl: credentialsBaseUrl ? `${credentialsBaseUrl}/records` : null,
accountSettingsUrl: context.config.ACCOUNT_SETTINGS_URL,
};
const ProfilePage = ({ params }) => {
const dispatch = useDispatch();
const intl = useIntl();
const context = useContext(AppContext);
const {
dateJoined,
courseCertificates,
name,
visibilityName,
profileImage,
savePhotoState,
isLoadingProfile,
photoUploadError,
country,
visibilityCountry,
levelOfEducation,
visibilityLevelOfEducation,
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
languageProficiencies,
visibilityLanguageProficiencies,
bio,
visibilityBio,
saveState,
username,
} = useSelector(profilePageSelector);
this.handleSaveProfilePhoto = this.handleSaveProfilePhoto.bind(this);
this.handleDeleteProfilePhoto = this.handleDeleteProfilePhoto.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleChange = this.handleChange.bind(this);
}
const navigate = useNavigate();
const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null);
const isMobileView = useIsOnMobileScreen();
const isTabletView = useIsOnTabletScreen();
componentDidMount() {
this.props.fetchProfile(this.props.params.username);
useEffect(() => {
const { CREDENTIALS_BASE_URL } = context.config;
if (CREDENTIALS_BASE_URL) {
setViewMyRecordsUrl(`${CREDENTIALS_BASE_URL}/records`);
}
dispatch(fetchProfile(params.username));
sendTrackingLogEvent('edx.profile.viewed', {
username: this.props.params.username,
username: params.username,
});
}
}, [dispatch, params.username, context.config]);
handleSaveProfilePhoto(formData) {
this.props.saveProfilePhoto(this.context.authenticatedUser.username, formData);
}
useEffect(() => {
if (!username && saveState === 'error' && navigate) {
navigate('/notfound');
}
}, [username, saveState, navigate]);
handleDeleteProfilePhoto() {
this.props.deleteProfilePhoto(this.context.authenticatedUser.username);
}
const authenticatedUserName = context.authenticatedUser.username;
handleClose(formId) {
this.props.closeForm(formId);
}
const handleSaveProfilePhoto = useCallback((formData) => {
dispatch(saveProfilePhoto(authenticatedUserName, formData));
}, [dispatch, authenticatedUserName]);
handleOpen(formId) {
this.props.openForm(formId);
}
const handleDeleteProfilePhoto = useCallback(() => {
dispatch(deleteProfilePhoto(authenticatedUserName));
}, [dispatch, authenticatedUserName]);
handleSubmit(formId) {
this.props.saveProfile(formId, this.context.authenticatedUser.username);
}
const handleClose = useCallback((formId) => {
dispatch(closeForm(formId));
}, [dispatch]);
handleChange(name, value) {
this.props.updateDraft(name, value);
}
const handleOpen = useCallback((formId) => {
dispatch(openForm(formId));
}, [dispatch]);
isYOBDisabled() {
const { yearOfBirth } = this.props;
const currentYear = new Date().getFullYear();
const isAgeOrNotCompliant = !yearOfBirth || ((currentYear - yearOfBirth) < 13);
const handleSubmit = useCallback((formId) => {
dispatch(saveProfile(formId, authenticatedUserName));
}, [dispatch, authenticatedUserName]);
return isAgeOrNotCompliant && getConfig().COLLECT_YEAR_OF_BIRTH !== 'true';
}
const handleChange = useCallback((fieldName, value) => {
dispatch(updateDraft(fieldName, value));
}, [dispatch]);
isAuthenticatedUserProfile() {
return this.props.params.username === this.context.authenticatedUser.username;
}
const isAuthenticatedUserProfile = () => params.username === authenticatedUserName;
// Inserted into the DOM in two places (for responsive layout)
renderViewMyRecordsButton() {
if (!(this.state.viewMyRecordsUrl && this.isAuthenticatedUserProfile())) {
const isBlockVisible = (blockInfo) => isAuthenticatedUserProfile()
|| (!isAuthenticatedUserProfile() && Boolean(blockInfo));
const renderViewMyRecordsButton = () => {
if (!(viewMyRecordsUrl && isAuthenticatedUserProfile())) {
return null;
}
return (
<Hyperlink className="btn btn-primary" destination={this.state.viewMyRecordsUrl} target="_blank">
{this.props.intl.formatMessage(messages['profile.viewMyRecords'])}
<Hyperlink
className={classNames(
'btn btn-brand bg-brand-500 font-weight-normal px-4 py-10px text-nowrap',
{ 'w-100': isMobileView },
)}
target="_blank"
showLaunchIcon={false}
destination={viewMyRecordsUrl}
>
{intl.formatMessage(messages['profile.viewMyRecords'])}
</Hyperlink>
);
}
};
// Inserted into the DOM in two places (for responsive layout)
renderHeadingLockup() {
const { dateJoined } = this.props;
return (
<span data-hj-suppress>
<h1 className="h2 mb-0 font-weight-bold text-truncate">{this.props.params.username}</h1>
<DateJoined date={dateJoined} />
{this.isYOBDisabled() && <UsernameDescription />}
<hr className="d-none d-md-block" />
</span>
);
}
renderPhotoUploadErrorMessage() {
const { photoUploadError } = this.props;
if (photoUploadError === null) {
return null;
}
return (
const renderPhotoUploadErrorMessage = () => (
photoUploadError && (
<div className="row">
<div className="col-md-4 col-lg-3">
<Alert variant="danger" dismissible={false} show>
@@ -148,227 +157,270 @@ class ProfilePage extends React.Component {
</Alert>
</div>
</div>
);
}
)
);
renderAgeMessage() {
const { requiresParentalConsent } = this.props;
const shouldShowAgeMessage = requiresParentalConsent && this.isAuthenticatedUserProfile();
const commonFormProps = {
openHandler: handleOpen,
closeHandler: handleClose,
submitHandler: handleSubmit,
changeHandler: handleChange,
};
if (!shouldShowAgeMessage) {
return null;
}
return <AgeMessage accountSettingsUrl={this.state.accountSettingsUrl} />;
}
renderContent() {
const {
profileImage,
name,
visibilityName,
country,
visibilityCountry,
levelOfEducation,
visibilityLevelOfEducation,
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
learningGoal,
visibilityLearningGoal,
languageProficiencies,
visibilityLanguageProficiencies,
courseCertificates,
visibilityCourseCertificates,
bio,
visibilityBio,
requiresParentalConsent,
isLoadingProfile,
username,
saveState,
navigate,
} = this.props;
if (isLoadingProfile) {
return <PageLoading srMessage={this.props.intl.formatMessage(messages['profile.loading'])} />;
}
if (!username && saveState === 'error' && navigate) {
navigate('/notfound');
}
const commonFormProps = {
openHandler: this.handleOpen,
closeHandler: this.handleClose,
submitHandler: this.handleSubmit,
changeHandler: this.handleChange,
};
const isBlockVisible = (blockInfo) => this.isAuthenticatedUserProfile()
|| (!this.isAuthenticatedUserProfile() && Boolean(blockInfo));
const isLanguageBlockVisible = isBlockVisible(languageProficiencies.length);
const isEducationBlockVisible = isBlockVisible(levelOfEducation);
const isSocialLinksBLockVisible = isBlockVisible(socialLinks.some((link) => link.socialLink !== null));
const isBioBlockVisible = isBlockVisible(bio);
const isCertificatesBlockVisible = isBlockVisible(courseCertificates.length);
const isNameBlockVisible = isBlockVisible(name);
const isLocationBlockVisible = isBlockVisible(country);
return (
<div className="container-fluid">
<div className="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0">
<div className="col-auto col-md-4 col-lg-3">
<div className="d-flex align-items-center d-md-block">
<ProfileAvatar
className="mb-md-3"
src={profileImage.src}
isDefault={profileImage.isDefault}
onSave={this.handleSaveProfilePhoto}
onDelete={this.handleDeleteProfilePhoto}
savePhotoState={this.props.savePhotoState}
isEditable={this.isAuthenticatedUserProfile() && !requiresParentalConsent}
/>
return (
<div className="profile-page">
{isLoadingProfile ? (
<PageLoading srMessage={intl.formatMessage(messages['profile.loading'])} />
) : (
<>
<div
className={classNames(
'profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100',
{ 'px-3 py-4': isMobileView },
{ 'px-120px py-5.5': !isMobileView },
)}
>
<div
className={classNames([
'col container-fluid w-100 h-100 bg-white py-0 rounded-75',
{
'px-3': isMobileView,
'px-40px': !isMobileView,
},
])}
>
<div
className={classNames([
'col h-100 w-100 px-0 justify-content-start g-15rem',
{
'py-4': isMobileView,
'py-36px': !isMobileView,
},
])}
>
<div
className={classNames([
'row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem',
isMobileView || isTabletView ? 'flex-column' : 'flex-row',
])}
>
<ProfileAvatar
className="col p-0"
src={profileImage.src}
isDefault={profileImage.isDefault}
onSave={handleSaveProfilePhoto}
onDelete={handleDeleteProfilePhoto}
savePhotoState={savePhotoState}
isEditable={isAuthenticatedUserProfile()}
/>
<div
className={classNames([
'col h-100 w-100 m-0 p-0',
isMobileView || isTabletView
? 'd-flex flex-column justify-content-center align-items-center'
: 'justify-content-start align-items-start',
])}
>
<p className="row m-0 font-weight-bold text-truncate text-primary-500 h3">
{params.username}
</p>
{isBlockVisible(name) && (
<p className="row pt-2 text-gray-800 font-weight-normal m-0 p">
{name}
</p>
)}
<div className={classNames(
'row pt-2 m-0',
isMobileView
? 'd-flex justify-content-center align-items-center flex-column'
: 'g-1rem',
)}
>
<DateJoined date={dateJoined} />
<UserCertificateSummary count={courseCertificates?.length || 0} />
</div>
</div>
<div className={classNames([
'p-0 ',
isMobileView || isTabletView ? 'col d-flex justify-content-center' : 'col-auto',
])}
>
{renderViewMyRecordsButton()}
</div>
</div>
</div>
<div className="ml-auto">
{renderPhotoUploadErrorMessage()}
</div>
</div>
</div>
<div className="col">
<div className="d-md-none">
{this.renderHeadingLockup()}
</div>
<div className="d-none d-md-block float-right">
{this.renderViewMyRecordsButton()}
</div>
</div>
</div>
{this.renderPhotoUploadErrorMessage()}
<div className="row">
<div className="col-md-4 col-lg-4">
<div className="d-none d-md-block mb-4">
{this.renderHeadingLockup()}
</div>
<div className="d-md-none mb-4">
{this.renderViewMyRecordsButton()}
</div>
{isNameBlockVisible && (
<Name
name={name}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
)}
{isLocationBlockVisible && (
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
)}
{isLanguageBlockVisible && (
<PreferredLanguage
languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
)}
{isEducationBlockVisible && (
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
)}
{isSocialLinksBLockVisible && (
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
)}
</div>
<div className="pt-md-3 col-md-8 col-lg-7 offset-lg-1">
{!this.isYOBDisabled() && this.renderAgeMessage()}
{isBioBlockVisible && (
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
)}
{getConfig().ENABLE_SKILLS_BUILDER_PROFILE && (
<LearningGoal
learningGoal={learningGoal}
visibilityLearningGoal={visibilityLearningGoal}
formId="learningGoal"
{...commonFormProps}
/>
)}
{isCertificatesBlockVisible && (
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
)}
</div>
</div>
</div>
);
}
<div
className={classNames([
'col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem',
isMobileView ? 'py-4 px-3' : 'px-120px py-6',
])}
>
<div className="w-100 p-0">
<div className="col justify-content-start align-items-start p-0">
<div className="col align-self-stretch height-42px justify-content-start align-items-start p-0">
<p className="font-weight-bold text-primary-500 m-0 h2">
{isMobileView ? (
<FormattedMessage
id="profile.profile.information"
defaultMessage="Profile"
description="heading for the editable profile section in mobile view"
/>
)
: (
<FormattedMessage
id="profile.profile.information"
defaultMessage="Profile information"
description="heading for the editable profile section"
/>
)}
</p>
</div>
</div>
<div
className={classNames([
'row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start',
isMobileView ? 'pt-4' : 'pt-5.5',
])}
>
<div
className={classNames([
'col p-0',
isMobileView ? 'col-12' : 'col-6',
])}
>
<div className="m-0">
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.username'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.username.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<h4 className="edit-section-header text-gray-700">
{params.username}
</h4>
</div>
{isBlockVisible(name) && (
<Name
name={name}
accountSettingsUrl={context.config.ACCOUNT_SETTINGS_URL}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
)}
{isBlockVisible(country) && (
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
)}
{isBlockVisible((languageProficiencies || []).length) && (
<PreferredLanguage
languageProficiencies={languageProficiencies || []}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
)}
{isBlockVisible(levelOfEducation) && (
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
)}
render() {
return (
<div className="profile-page">
<Banner />
{this.renderContent()}
</div>
);
}
}
<AdditionalProfileFieldsSlot />
</div>
<div
className={classNames([
'col m-0 pr-0',
isMobileView ? 'pl-0 col-12' : 'pl-40px col-6',
])}
>
{isBlockVisible(bio) && (
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
)}
ProfilePage.contextType = AppContext;
{isBlockVisible((socialLinks || []).some((link) => link?.socialLink !== null)) && (
<SocialLinks
socialLinks={socialLinks || []}
draftSocialLinksByPlatform={draftSocialLinksByPlatform || {}}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
)}
</div>
</div>
</div>
</div>
<div
className={classNames([
'col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem',
isMobileView ? 'py-4 px-3' : 'px-120px py-6',
])}
>
{isBlockVisible((courseCertificates || []).length) && (
<Certificates
certificates={courseCertificates || []}
formId="certificates"
/>
)}
</div>
</>
)}
</div>
);
};
ProfilePage.propTypes = {
// Account data
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
requiresParentalConsent: PropTypes.bool,
dateJoined: PropTypes.string,
username: PropTypes.string,
// Bio form data
bio: PropTypes.string,
yearOfBirth: PropTypes.number,
visibilityBio: PropTypes.string.isRequired,
// Certificates form data
visibilityBio: PropTypes.string,
courseCertificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
visibilityCourseCertificates: PropTypes.string.isRequired,
// Country form data
country: PropTypes.string,
visibilityCountry: PropTypes.string.isRequired,
// Education form data
visibilityCountry: PropTypes.string,
levelOfEducation: PropTypes.string,
visibilityLevelOfEducation: PropTypes.string.isRequired,
// Language proficiency form data
visibilityLevelOfEducation: PropTypes.string,
languageProficiencies: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
})),
visibilityLanguageProficiencies: PropTypes.string.isRequired,
// Name form data
visibilityLanguageProficiencies: PropTypes.string,
name: PropTypes.string,
visibilityName: PropTypes.string.isRequired,
// Social links form data
visibilityName: PropTypes.string,
socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
@@ -377,41 +429,15 @@ ProfilePage.propTypes = {
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
visibilitySocialLinks: PropTypes.string.isRequired,
// Learning Goal form data
learningGoal: PropTypes.string,
visibilityLearningGoal: PropTypes.string.isRequired,
// Other data we need
visibilitySocialLinks: PropTypes.string,
profileImage: PropTypes.shape({
src: PropTypes.string,
isDefault: PropTypes.bool,
}),
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
isLoadingProfile: PropTypes.bool.isRequired,
// Page state helpers
isLoadingProfile: PropTypes.bool,
photoUploadError: PropTypes.objectOf(PropTypes.string),
// Actions
fetchProfile: PropTypes.func.isRequired,
saveProfile: PropTypes.func.isRequired,
saveProfilePhoto: PropTypes.func.isRequired,
deleteProfilePhoto: PropTypes.func.isRequired,
openForm: PropTypes.func.isRequired,
closeForm: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
// Router
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
// i18n
intl: intlShape.isRequired,
};
ProfilePage.defaultProps = {
@@ -421,28 +447,22 @@ ProfilePage.defaultProps = {
photoUploadError: {},
profileImage: {},
name: null,
yearOfBirth: null,
levelOfEducation: null,
country: null,
socialLinks: [],
draftSocialLinksByPlatform: {},
bio: null,
learningGoal: null,
languageProficiencies: [],
courseCertificates: null,
courseCertificates: [],
requiresParentalConsent: null,
dateJoined: null,
visibilityName: null,
visibilityCountry: null,
visibilityLevelOfEducation: null,
visibilitySocialLinks: null,
visibilityLanguageProficiencies: null,
visibilityBio: null,
isLoadingProfile: false,
};
export default connect(
profilePageSelector,
{
fetchProfile,
saveProfilePhoto,
deleteProfilePhoto,
saveProfile,
openForm,
closeForm,
updateDraft,
},
)(injectIntl(withParams(ProfilePage)));
export default withParams(ProfilePage);

View File

@@ -11,6 +11,16 @@ const messages = defineMessages({
defaultMessage: 'Profile loading...',
description: 'Message displayed when the profile data is loading.',
},
'profile.username': {
id: 'profile.username',
defaultMessage: 'Username',
description: 'Label for the username field.',
},
'profile.username.tooltip': {
id: 'profile.username.tooltip',
defaultMessage: 'The name that identifies you on edX. You cannot change your username.',
description: 'Tooltip for the username field.',
},
});
export default messages;

View File

@@ -1,4 +1,3 @@
/* eslint-disable global-require */
import { getConfig } from '@edx/frontend-platform';
import * as analytics from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
@@ -9,31 +8,38 @@ import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { BrowserRouter, useNavigate } from 'react-router-dom';
import {
MemoryRouter,
Routes,
Route,
useNavigate,
} from 'react-router-dom';
import messages from '../i18n';
import ProfilePage from './ProfilePage';
import loadingApp from './__mocks__/loadingApp.mockStore';
import viewOwnProfile from './__mocks__/viewOwnProfile.mockStore';
import viewOtherProfile from './__mocks__/viewOtherProfile.mockStore';
import invalidUser from './__mocks__/invalidUser.mockStore';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
const storeMocks = {
loadingApp: require('./__mocks__/loadingApp.mockStore'),
invalidUser: require('./__mocks__/invalidUser.mockStore'),
viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore'),
viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore'),
savingEditedBio: require('./__mocks__/savingEditedBio.mockStore'),
loadingApp,
viewOwnProfile,
viewOtherProfile,
invalidUser,
};
const requiredProfilePageProps = {
fetchUserAccount: () => {},
fetchProfile: () => {},
saveProfile: () => {},
saveProfilePhoto: () => {},
deleteProfilePhoto: () => {},
openField: () => {},
closeField: () => {},
params: { username: 'staff' },
};
// Mock language cookie
Object.defineProperty(global.document, 'cookie', {
writable: true,
value: `${getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME}=en`,
@@ -65,54 +71,39 @@ configureI18n({
beforeEach(() => {
analytics.sendTrackingLogEvent.mockReset();
useNavigate.mockReset();
});
const ProfileWrapper = ({ params, requiresParentalConsent }) => {
const navigate = useNavigate();
return (
<ProfilePage
{...requiredProfilePageProps}
params={params}
requiresParentalConsent={requiresParentalConsent}
navigate={navigate}
/>
);
};
ProfileWrapper.propTypes = {
params: PropTypes.shape({}).isRequired,
requiresParentalConsent: PropTypes.bool.isRequired,
};
const ProfilePageWrapper = ({
contextValue, store, params, requiresParentalConsent,
contextValue, store, params,
}) => (
<AppContext.Provider
value={contextValue}
>
<AppContext.Provider value={contextValue}>
<IntlProvider locale="en">
<Provider store={store}>
<BrowserRouter>
<ProfileWrapper
params={params}
requiresParentalConsent={requiresParentalConsent}
/>
</BrowserRouter>
<MemoryRouter initialEntries={[`/profile/${params.username}`]}>
<Routes>
<Route
path="/profile/:username"
element={<ProfilePage {...requiredProfilePageProps} params={params} />}
/>
</Routes>
</MemoryRouter>
</Provider>
</IntlProvider>
</AppContext.Provider>
);
ProfilePageWrapper.defaultProps = {
// eslint-disable-next-line react/default-props-match-prop-types
params: { username: 'staff' },
requiresParentalConsent: null,
};
ProfilePageWrapper.propTypes = {
contextValue: PropTypes.shape({}).isRequired,
store: PropTypes.shape({}).isRequired,
params: PropTypes.shape({}),
requiresParentalConsent: PropTypes.bool,
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
};
describe('<ProfilePage />', () => {
@@ -122,17 +113,12 @@ describe('<ProfilePage />', () => {
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.loadingApp)} />;
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('successfully redirected to not found page.', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.invalidUser)} />;
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.loadingApp)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
@@ -142,7 +128,12 @@ describe('<ProfilePage />', () => {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.viewOwnProfile)} />;
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.viewOwnProfile)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
@@ -152,7 +143,6 @@ describe('<ProfilePage />', () => {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
@@ -162,105 +152,26 @@ describe('<ProfilePage />', () => {
...storeMocks.viewOtherProfile.profilePage,
account: {
...storeMocks.viewOtherProfile.profilePage.account,
name: 'user',
country: 'EN',
bio: 'bio',
courseCertificates: ['course certificates'],
levelOfEducation: 'some level',
languageProficiencies: ['some lang'],
socialLinks: ['twitter'],
timeZone: 'time zone',
accountPrivacy: 'all_users',
name: 'Verified User',
country: 'US',
bio: 'About me',
courseCertificates: [{ title: 'Course 1' }],
levelOfEducation: 'bachelors',
languageProficiencies: [{ code: 'en' }],
socialLinks: [{ platform: 'twitter', socialLink: 'https://twitter.com/user' }],
},
preferences: {
...storeMocks.viewOtherProfile.profilePage.preferences,
visibilityName: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityBio: 'all_users',
},
},
})}
match={{ params: { username: 'verified' } }} // Override default match
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('while saving an edited bio', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.savingEditedBio)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('while saving an edited bio with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.bio = { userMessage: 'bio error' };
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test country edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.country = { userMessage: 'country error' };
storeData.profilePage.currentlyEditingField = 'country';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test education edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.levelOfEducation = { userMessage: 'education error' };
storeData.profilePage.currentlyEditingField = 'levelOfEducation';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test preferreded language edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.languageProficiencies = { userMessage: 'preferred language error' };
storeData.profilePage.currentlyEditingField = 'languageProficiencies';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
params={{ username: 'verified' }}
/>
);
const { container: tree } = render(component);
@@ -284,63 +195,24 @@ describe('<ProfilePage />', () => {
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test age message alert', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
storeData.userAccount.requiresParentalConsent = true;
storeData.profilePage.account.requiresParentalConsent = true;
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
};
const { container } = render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
requiresParentalConsent
/>,
);
expect(container.querySelector('.alert-info')).toHaveClass('show');
});
it('test photo error alert', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
storeData.profilePage.errors.photo = { userMessage: 'error' };
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
};
const { container } = render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
requiresParentalConsent
/>,
);
expect(container.querySelector('.alert-danger')).toHaveClass('show');
});
it.each([
['test user with non-disabled country', 'PK'],
['test user with disabled country', 'RU'],
])('test user with %s', (_, accountCountry) => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.country = {};
storeData.profilePage.currentlyEditingField = 'country';
storeData.profilePage.disabledCountries = ['RU'];
storeData.profilePage.account.country = accountCountry;
it('successfully redirected to not found page', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const navigate = jest.fn();
useNavigate.mockReturnValue(navigate);
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
store={mockStore(storeMocks.invalidUser)}
params={{ username: 'staffTest' }}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
expect(navigate).toHaveBeenCalledWith('/notfound');
});
});
@@ -358,11 +230,30 @@ describe('<ProfilePage />', () => {
/>,
);
expect(analytics.sendTrackingLogEvent.mock.calls.length).toBe(1);
expect(analytics.sendTrackingLogEvent.mock.calls[0][0]).toEqual('edx.profile.viewed');
expect(analytics.sendTrackingLogEvent.mock.calls[0][1]).toEqual({
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledTimes(1);
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledWith('edx.profile.viewed', {
username: 'test-username',
});
});
});
describe('handles navigation', () => {
it('navigates to notfound on save error with no username', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const navigate = jest.fn();
useNavigate.mockReturnValue(navigate);
render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.invalidUser)}
params={{ username: 'staffTest' }}
/>,
);
expect(navigate).toHaveBeenCalledWith('/notfound');
});
});
});

View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const UserCertificateSummary = ({ count = 0 }) => {
if (count) {
return (
<span className="small m-0 text-gray-800">
<FormattedMessage
id="profile.certificatecount"
defaultMessage="{certificate_count} certifications"
description="A label for many certificates a user has"
values={{
certificate_count: <span className="font-weight-bold">{count}</span>,
}}
/>
</span>
);
}
return null;
};
UserCertificateSummary.propTypes = {
count: PropTypes.number,
};
export default UserCertificateSummary;

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { VisibilityOff } from '@openedx/paragon/icons';
import { Icon } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
const UsernameDescription = () => (
<div className="d-flex align-items-center mt-3 mb-2rem">
<Icon src={VisibilityOff} className="icon-visibility-off" />
<div className="username-description">
<FormattedMessage
id="profile.username.description"
defaultMessage="Your profile information is only visible to you. Only your username is visible to others on {siteName}."
description="A description of the username field"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
</div>
</div>
);
export default UsernameDescription;

View File

@@ -29,6 +29,7 @@ module.exports = {
drafts: {},
isLoadingProfile: false,
isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {

View File

@@ -29,6 +29,7 @@ module.exports = {
drafts: {},
isLoadingProfile: true,
isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {

View File

@@ -81,11 +81,18 @@ module.exports = {
gender: null,
accountPrivacy: 'private'
},
preferences: {},
preferences: {
visibilityName: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilitySocialLinks: 'all_users',
visibilityBio: 'all_users'
},
courseCertificates: [],
drafts: {},
isLoadingProfile: false,
learningGoal: 'advance_career',
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {

View File

@@ -125,7 +125,8 @@ module.exports = {
}
],
drafts: {},
isLoadingProfile: false
isLoadingProfile: false,
countriesCodesList: ['US', 'CA', 'GB', 'ME']
},
router: {
location: {

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,6 @@ export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
// FETCH PROFILE ACTIONS
export const fetchProfile = username => ({
type: FETCH_PROFILE.BASE,
payload: { username },
@@ -25,20 +23,20 @@ export const fetchProfileSuccess = (
preferences,
courseCertificates,
isAuthenticatedUserProfile,
countriesCodesList,
) => ({
type: FETCH_PROFILE.SUCCESS,
account,
preferences,
courseCertificates,
isAuthenticatedUserProfile,
countriesCodesList,
});
export const fetchProfileReset = () => ({
type: FETCH_PROFILE.RESET,
});
// SAVE PROFILE ACTIONS
export const saveProfile = (formId, username) => ({
type: SAVE_PROFILE.BASE,
payload: {
@@ -68,8 +66,6 @@ export const saveProfileFailure = errors => ({
payload: { errors },
});
// SAVE PROFILE PHOTO ACTIONS
export const saveProfilePhoto = (username, formData) => ({
type: SAVE_PROFILE_PHOTO.BASE,
payload: {
@@ -96,8 +92,6 @@ export const saveProfilePhotoFailure = error => ({
payload: { error },
});
// DELETE PROFILE PHOTO ACTIONS
export const deleteProfilePhoto = username => ({
type: DELETE_PROFILE_PHOTO.BASE,
payload: {
@@ -118,8 +112,6 @@ export const deleteProfilePhotoReset = () => ({
type: DELETE_PROFILE_PHOTO.RESET,
});
// FIELD STATE ACTIONS
export const openForm = formId => ({
type: OPEN_FORM,
payload: {
@@ -134,8 +126,6 @@ export const closeForm = formId => ({
},
});
// FORM STATE ACTIONS
export const updateDraft = (name, value) => ({
type: UPDATE_DRAFT,
payload: {

View File

@@ -1,14 +1,4 @@
import {
openForm,
closeForm,
OPEN_FORM,
CLOSE_FORM,
SAVE_PROFILE,
saveProfileBegin,
saveProfileSuccess,
saveProfileFailure,
saveProfileReset,
saveProfile,
SAVE_PROFILE_PHOTO,
saveProfilePhotoBegin,
saveProfilePhotoSuccess,
@@ -22,76 +12,6 @@ import {
deleteProfilePhoto,
} from './actions';
describe('editable field actions', () => {
it('should create an open action', () => {
const expectedAction = {
type: OPEN_FORM,
payload: {
formId: 'name',
},
};
expect(openForm('name')).toEqual(expectedAction);
});
it('should create a closed action', () => {
const expectedAction = {
type: CLOSE_FORM,
payload: {
formId: 'name',
},
};
expect(closeForm('name')).toEqual(expectedAction);
});
});
describe('SAVE profile actions', () => {
it('should create an action to signal the start of a profile save', () => {
const expectedAction = {
type: SAVE_PROFILE.BASE,
payload: {
formId: 'name',
},
};
expect(saveProfile('name')).toEqual(expectedAction);
});
it('should create an action to signal user profile save success', () => {
const accountData = { name: 'Full Name' };
const preferencesData = { visibility: { name: 'private' } };
const expectedAction = {
type: SAVE_PROFILE.SUCCESS,
payload: {
account: accountData,
preferences: preferencesData,
},
};
expect(saveProfileSuccess(accountData, preferencesData)).toEqual(expectedAction);
});
it('should create an action to signal user profile save beginning', () => {
const expectedAction = {
type: SAVE_PROFILE.BEGIN,
};
expect(saveProfileBegin()).toEqual(expectedAction);
});
it('should create an action to signal user profile save success', () => {
const expectedAction = {
type: SAVE_PROFILE.RESET,
};
expect(saveProfileReset()).toEqual(expectedAction);
});
it('should create an action to signal user account save failure', () => {
const errors = ['Test failure'];
const expectedAction = {
type: SAVE_PROFILE.FAILURE,
payload: { errors },
};
expect(saveProfileFailure(errors)).toEqual(expectedAction);
});
});
describe('SAVE profile photo actions', () => {
it('should create an action to signal the start of a profile photo save', () => {
const formData = 'multipart form data';
@@ -123,7 +43,7 @@ describe('SAVE profile photo actions', () => {
expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo save success', () => {
it('should create an action to signal user profile photo save reset', () => {
const expectedAction = {
type: SAVE_PROFILE_PHOTO.RESET,
};
@@ -169,34 +89,10 @@ describe('DELETE profile photo actions', () => {
expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction);
});
it('should create an action to signal user profile photo deletion success', () => {
it('should create an action to signal user profile photo deletion reset', () => {
const expectedAction = {
type: DELETE_PROFILE_PHOTO.RESET,
};
expect(deleteProfilePhotoReset()).toEqual(expectedAction);
});
});
describe('Editable field opening and closing actions', () => {
const formId = 'name';
it('should create an action to signal the opening a field', () => {
const expectedAction = {
type: OPEN_FORM,
payload: {
formId,
},
};
expect(openForm(formId)).toEqual(expectedAction);
});
it('should create an action to signal the closing a field', () => {
const expectedAction = {
type: CLOSE_FORM,
payload: {
formId,
},
};
expect(closeForm(formId)).toEqual(expectedAction);
});
});

View File

@@ -22,7 +22,12 @@ const SOCIAL = {
},
};
const FIELD_LABELS = {
COUNTRY: 'country',
};
export {
EDUCATION_LEVELS,
SOCIAL,
FIELD_LABELS,
};

34
src/profile/data/hooks.js Normal file
View File

@@ -0,0 +1,34 @@
import { breakpoints, useWindowSize } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
export function useIsOnTabletScreen() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.medium.minWidth;
}
export function useIsOnMobileScreen() {
const windowSize = useWindowSize();
return windowSize.width <= breakpoints.small.minWidth;
}
export function useIsVisibilityEnabled() {
return getConfig().DISABLE_VISIBILITY_EDITING !== 'true';
}
export function useHandleChange(changeHandler) {
return (e) => {
const { name, value } = e.target;
changeHandler(name, value);
};
}
export function useHandleSubmit(submitHandler, formId) {
return (e) => {
e.preventDefault();
submitHandler(formId);
};
}
export function useCloseOpenHandler(handler, formId) {
return () => handler(formId);
}

View File

@@ -17,6 +17,10 @@ const expectedUserInfo200 = {
dateJoined: '2017-06-07T00:44:23Z',
isActive: true,
yearOfBirth: 1901,
languageProficiencies: [],
levelOfEducation: null,
profileImage: {},
socialLinks: [],
};
const provider = new PactV3({

View File

@@ -16,13 +16,28 @@ export const initialState = {
currentlyEditingField: null,
account: {
socialLinks: [],
languageProficiencies: [],
name: '',
bio: '',
country: '',
levelOfEducation: '',
profileImage: {},
yearOfBirth: '',
},
preferences: {
visibilityName: '',
visibilityBio: '',
visibilityCountry: '',
visibilityLevelOfEducation: '',
visibilitySocialLinks: '',
visibilityLanguageProficiencies: '',
},
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: true,
isAuthenticatedUserProfile: false,
disabledCountries: ['RU'],
countriesCodesList: [],
};
const profilePage = (state = initialState, action = {}) => {
@@ -38,11 +53,17 @@ const profilePage = (state = initialState, action = {}) => {
case FETCH_PROFILE.SUCCESS:
return {
...state,
account: action.account,
account: {
...state.account,
...action.account,
socialLinks: action.account.socialLinks || [],
languageProficiencies: action.account.languageProficiencies || [],
},
preferences: action.preferences,
courseCertificates: action.courseCertificates,
courseCertificates: action.courseCertificates || [],
isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
countriesCodesList: action.countriesCodesList || [],
};
case SAVE_PROFILE.BEGIN:
return {
@@ -55,9 +76,12 @@ const profilePage = (state = initialState, action = {}) => {
...state,
saveState: 'complete',
errors: {},
// Account is always replaced completely.
account: action.payload.account !== null ? action.payload.account : state.account,
// Preferences changes get merged in.
account: action.payload.account !== null ? {
...state.account,
...action.payload.account,
socialLinks: action.payload.account.socialLinks || [],
languageProficiencies: action.payload.account.languageProficiencies || [],
} : state.account,
preferences: { ...state.preferences, ...action.payload.preferences },
};
case SAVE_PROFILE.FAILURE:
@@ -74,7 +98,6 @@ const profilePage = (state = initialState, action = {}) => {
isLoadingProfile: false,
errors: {},
};
case SAVE_PROFILE_PHOTO.BEGIN:
return {
...state,
@@ -84,7 +107,6 @@ const profilePage = (state = initialState, action = {}) => {
case SAVE_PROFILE_PHOTO.SUCCESS:
return {
...state,
// Merge in new profile image data
account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
@@ -101,7 +123,6 @@ const profilePage = (state = initialState, action = {}) => {
savePhotoState: null,
errors: {},
};
case DELETE_PROFILE_PHOTO.BEGIN:
return {
...state,
@@ -111,7 +132,6 @@ const profilePage = (state = initialState, action = {}) => {
case DELETE_PROFILE_PHOTO.SUCCESS:
return {
...state,
// Merge in new profile image data (should be empty or default image)
account: { ...state.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
@@ -128,13 +148,11 @@ const profilePage = (state = initialState, action = {}) => {
savePhotoState: null,
errors: {},
};
case UPDATE_DRAFT:
return {
...state,
drafts: { ...state.drafts, [action.payload.name]: action.payload.value },
};
case RESET_DRAFTS:
return {
...state,
@@ -147,7 +165,6 @@ const profilePage = (state = initialState, action = {}) => {
drafts: {},
};
case CLOSE_FORM:
// Only close if the field to close is undefined or matches the field that is currently open
if (action.payload.formId === state.currentlyEditingField) {
return {
...state,

View File

@@ -0,0 +1,309 @@
import profilePage, { initialState } from './reducers';
import {
SAVE_PROFILE,
SAVE_PROFILE_PHOTO,
DELETE_PROFILE_PHOTO,
CLOSE_FORM,
OPEN_FORM,
FETCH_PROFILE,
UPDATE_DRAFT,
RESET_DRAFTS,
} from './actions';
describe('profilePage reducer', () => {
it('should return the initial state by default', () => {
expect(profilePage(undefined, {})).toEqual(initialState);
});
describe('FETCH_PROFILE actions', () => {
it('should handle FETCH_PROFILE.BEGIN', () => {
const action = { type: FETCH_PROFILE.BEGIN };
const expectedState = {
...initialState,
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle FETCH_PROFILE.SUCCESS', () => {
const action = {
type: FETCH_PROFILE.SUCCESS,
account: {
name: 'John Doe',
bio: 'Software Engineer',
country: 'US',
levelOfEducation: 'bachelors',
socialLinks: [{ platform: 'twitter', link: 'twitter.com/johndoe' }],
languageProficiencies: [{ code: 'en', name: 'English' }],
profileImage: { url: 'profile.jpg' },
yearOfBirth: 1990,
},
preferences: {
visibilityName: 'public',
visibilityBio: 'public',
visibilityCountry: 'public',
visibilityLevelOfEducation: 'public',
visibilitySocialLinks: 'public',
visibilityLanguageProficiencies: 'public',
},
courseCertificates: ['cert1', 'cert2'],
isAuthenticatedUserProfile: true,
countriesCodesList: ['US', 'CA'],
};
const expectedState = {
...initialState,
account: {
...initialState.account,
...action.account,
socialLinks: action.account.socialLinks,
languageProficiencies: action.account.languageProficiencies,
},
preferences: action.preferences,
courseCertificates: action.courseCertificates,
isLoadingProfile: false,
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
countriesCodesList: action.countriesCodesList,
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('SAVE_PROFILE actions', () => {
it('should handle SAVE_PROFILE.BEGIN', () => {
const action = { type: SAVE_PROFILE.BEGIN };
const expectedState = {
...initialState,
saveState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE.SUCCESS', () => {
const action = {
type: SAVE_PROFILE.SUCCESS,
payload: {
account: {
name: 'Jane Doe',
bio: 'Updated bio',
socialLinks: [{ platform: 'linkedin', link: 'linkedin.com/janedoe' }],
languageProficiencies: [{ code: 'es', name: 'Spanish' }],
},
preferences: {
visibilityName: 'private',
visibilityBio: 'private',
},
},
};
const expectedState = {
...initialState,
saveState: 'complete',
errors: {},
account: {
...initialState.account,
...action.payload.account,
socialLinks: action.payload.account.socialLinks,
languageProficiencies: action.payload.account.languageProficiencies,
},
preferences: {
...initialState.preferences,
...action.payload.preferences,
},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE.FAILURE', () => {
const action = {
type: SAVE_PROFILE.FAILURE,
payload: { errors: { save: 'Failed to save profile' } },
};
const expectedState = {
...initialState,
saveState: 'error',
isLoadingProfile: false,
errors: { save: action.payload.errors.save },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE.RESET', () => {
const action = { type: SAVE_PROFILE.RESET };
const expectedState = {
...initialState,
saveState: null,
isLoadingProfile: false,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('SAVE_PROFILE_PHOTO actions', () => {
it('should handle SAVE_PROFILE_PHOTO.BEGIN', () => {
const action = { type: SAVE_PROFILE_PHOTO.BEGIN };
const expectedState = {
...initialState,
savePhotoState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.SUCCESS', () => {
const action = {
type: SAVE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage: { url: 'new-image-url.jpg' } },
};
const expectedState = {
...initialState,
account: { ...initialState.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.FAILURE', () => {
const action = {
type: SAVE_PROFILE_PHOTO.FAILURE,
payload: { error: 'Photo upload failed' },
};
const expectedState = {
...initialState,
savePhotoState: 'error',
errors: { photo: action.payload.error },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle SAVE_PROFILE_PHOTO.RESET', () => {
const action = { type: SAVE_PROFILE_PHOTO.RESET };
const expectedState = {
...initialState,
savePhotoState: null,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('DELETE_PROFILE_PHOTO actions', () => {
it('should handle DELETE_PROFILE_PHOTO.BEGIN', () => {
const action = { type: DELETE_PROFILE_PHOTO.BEGIN };
const expectedState = {
...initialState,
savePhotoState: 'pending',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.SUCCESS', () => {
const action = {
type: DELETE_PROFILE_PHOTO.SUCCESS,
payload: { profileImage: { url: 'default-image-url.jpg' } },
};
const expectedState = {
...initialState,
account: { ...initialState.account, profileImage: action.payload.profileImage },
savePhotoState: 'complete',
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.FAILURE', () => {
const action = {
type: DELETE_PROFILE_PHOTO.FAILURE,
payload: { errors: { delete: 'Failed to delete photo' } },
};
const expectedState = {
...initialState,
savePhotoState: 'error',
errors: { delete: action.payload.errors.delete },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle DELETE_PROFILE_PHOTO.RESET', () => {
const action = { type: DELETE_PROFILE_PHOTO.RESET };
const expectedState = {
...initialState,
savePhotoState: null,
errors: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
});
describe('Draft and Form actions', () => {
it('should handle UPDATE_DRAFT', () => {
const action = {
type: UPDATE_DRAFT,
payload: { name: 'bio', value: 'New bio draft' },
};
const expectedState = {
...initialState,
drafts: { bio: 'New bio draft' },
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle RESET_DRAFTS', () => {
const initialStateWithDrafts = {
...initialState,
drafts: { bio: 'New bio draft', name: 'New name' },
};
const action = { type: RESET_DRAFTS };
const expectedState = {
...initialStateWithDrafts,
drafts: {},
};
expect(profilePage(initialStateWithDrafts, action)).toEqual(expectedState);
});
it('should handle OPEN_FORM', () => {
const action = {
type: OPEN_FORM,
payload: { formId: 'bioForm' },
};
const expectedState = {
...initialState,
currentlyEditingField: 'bioForm',
drafts: {},
};
expect(profilePage(initialState, action)).toEqual(expectedState);
});
it('should handle CLOSE_FORM when formId matches currentlyEditingField', () => {
const initialStateWithForm = {
...initialState,
currentlyEditingField: 'bioForm',
drafts: { bio: 'New bio draft' },
};
const action = {
type: CLOSE_FORM,
payload: { formId: 'bioForm' },
};
const expectedState = {
...initialStateWithForm,
currentlyEditingField: null,
drafts: {},
};
expect(profilePage(initialStateWithForm, action)).toEqual(expectedState);
});
it('should not handle CLOSE_FORM when formId does not match currentlyEditingField', () => {
const initialStateWithForm = {
...initialState,
currentlyEditingField: 'bioForm',
drafts: { bio: 'New bio draft' },
};
const action = {
type: CLOSE_FORM,
payload: { formId: 'nameForm' },
};
expect(profilePage(initialStateWithForm, action)).toEqual(initialStateWithForm);
});
});
});

View File

@@ -1,3 +1,4 @@
import { history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import pick from 'lodash.pick';
import {
@@ -21,13 +22,12 @@ import {
resetDrafts,
saveProfileBegin,
saveProfileFailure,
saveProfilePhotoBegin,
saveProfilePhotoFailure,
saveProfilePhotoReset,
saveProfilePhotoSuccess,
saveProfileReset,
saveProfileSuccess,
SAVE_PROFILE,
saveProfilePhotoBegin,
saveProfilePhotoReset,
saveProfilePhotoSuccess,
SAVE_PROFILE_PHOTO,
} from './actions';
import { handleSaveProfileSelector, userAccountSelector } from './selectors';
@@ -37,38 +37,32 @@ export function* handleFetchProfile(action) {
const { username } = action.payload;
const userAccount = yield select(userAccountSelector);
const isAuthenticatedUserProfile = username === getAuthenticatedUser().username;
// Default our data assuming the account is the current user's account.
let preferences = {};
let account = userAccount;
let courseCertificates = null;
let countriesCodesList = [];
try {
yield put(fetchProfileBegin());
// Depending on which profile we're loading, we need to make different calls.
const calls = [
call(ProfileApiService.getAccount, username),
call(ProfileApiService.getCourseCertificates, username),
call(ProfileApiService.getCountryList),
];
if (isAuthenticatedUserProfile) {
// If the profile is for the current user, get their preferences.
// We don't need them for other users.
calls.push(call(ProfileApiService.getPreferences, username));
}
// Make all the calls in parallel.
const result = yield all(calls);
if (isAuthenticatedUserProfile) {
[account, courseCertificates, preferences] = result;
[account, courseCertificates, countriesCodesList, preferences] = result;
} else {
[account, courseCertificates] = result;
[account, courseCertificates, countriesCodesList] = result;
}
// Set initial visibility values for account
// Set account_privacy as custom is necessary so that when viewing another user's profile,
// their full name is displayed and change visibility forms are worked correctly
if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') {
yield call(ProfileApiService.patchPreferences, action.payload.username, {
account_privacy: 'custom',
@@ -89,16 +83,13 @@ export function* handleFetchProfile(action) {
preferences,
courseCertificates,
isAuthenticatedUserProfile,
countriesCodesList,
));
yield put(fetchProfileReset());
} catch (e) {
if (e.response.status === 404) {
if (e.processedData && e.processedData.fieldErrors) {
yield put(saveProfileFailure(e.processedData.fieldErrors));
} else {
yield put(saveProfileFailure(e.customAttributes));
}
history.push('/notfound');
} else {
throw e;
}
@@ -111,7 +102,6 @@ export function* handleSaveProfile(action) {
const accountDrafts = pick(drafts, [
'bio',
'courseCertificates',
'country',
'levelOfEducation',
'languageProficiencies',
@@ -121,7 +111,6 @@ export function* handleSaveProfile(action) {
const preferencesDrafts = pick(drafts, [
'visibilityBio',
'visibilityCourseCertificates',
'visibilityCountry',
'visibilityLevelOfEducation',
'visibilityLanguageProficiencies',
@@ -135,7 +124,6 @@ export function* handleSaveProfile(action) {
yield put(saveProfileBegin());
let accountResult = null;
// Build the visibility drafts into a structure the API expects.
if (Object.keys(accountDrafts).length > 0) {
accountResult = yield call(
@@ -145,17 +133,14 @@ export function* handleSaveProfile(action) {
);
}
let preferencesResult = preferences; // assume it hasn't changed.
let preferencesResult = preferences;
if (Object.keys(preferencesDrafts).length > 0) {
yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts);
// TODO: Temporary deoptimization since the patchPreferences call doesn't return anything.
// Remove this second call once we can get a result from the one above.
preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username);
}
// The account result is returned from the server.
// The preferences draft is valid if the server didn't complain, so
// pass it through directly.
yield put(saveProfileSuccess(accountResult, preferencesResult));
yield delay(1000);
yield put(closeForm(action.payload.formId));
@@ -181,12 +166,7 @@ export function* handleSaveProfilePhoto(action) {
yield put(saveProfilePhotoSuccess(photoResult));
yield put(saveProfilePhotoReset());
} catch (e) {
if (e.processedData) {
yield put(saveProfilePhotoFailure(e.processedData));
} else {
yield put(saveProfilePhotoReset());
throw e;
}
yield put(saveProfilePhotoReset());
}
}
@@ -200,7 +180,6 @@ export function* handleDeleteProfilePhoto(action) {
yield put(deleteProfilePhotoReset());
} catch (e) {
yield put(deleteProfilePhotoReset());
throw e;
}
}

View File

@@ -19,13 +19,13 @@ jest.mock('./services', () => ({
getPreferences: jest.fn(),
getAccount: jest.fn(),
getCourseCertificates: jest.fn(),
getCountryList: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedUser: jest.fn(),
}));
// RootSaga and ProfileApiService must be imported AFTER the mock above.
/* eslint-disable import/first */
import profileSaga, {
handleFetchProfile,
@@ -68,17 +68,18 @@ describe('RootSaga', () => {
const action = profileActions.fetchProfile('gonzo');
const gen = handleFetchProfile(action);
const result = [userAccount, [1, 2, 3], { preferences: 'stuff' }];
const result = [userAccount, [1, 2, 3], [], { preferences: 'stuff' }];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'gonzo'),
call(ProfileApiService.getCourseCertificates, 'gonzo'),
call(ProfileApiService.getCountryList),
call(ProfileApiService.getPreferences, 'gonzo'),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[2], result[1], true)));
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[3], result[1], true, [])));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});
@@ -88,6 +89,7 @@ describe('RootSaga', () => {
username: 'gonzo',
other: 'data',
};
const countriesCodesList = [{ code: 'AX' }, { code: 'AL' }];
getAuthenticatedUser.mockReturnValue(userAccount);
const selectorData = {
userAccount,
@@ -96,16 +98,17 @@ describe('RootSaga', () => {
const action = profileActions.fetchProfile('booyah');
const gen = handleFetchProfile(action);
const result = [{}, [1, 2, 3]];
const result = [{}, [1, 2, 3], countriesCodesList];
expect(gen.next().value).toEqual(select(userAccountSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
expect(gen.next().value).toEqual(all([
call(ProfileApiService.getAccount, 'booyah'),
call(ProfileApiService.getCourseCertificates, 'booyah'),
call(ProfileApiService.getCountryList),
]));
expect(gen.next(result).value)
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false)));
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false, countriesCodesList)));
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
expect(gen.next().value).toBeUndefined();
});
@@ -132,8 +135,6 @@ describe('RootSaga', () => {
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', {
name: 'Full Name',
}));
// The library would supply the result of the above call
// as the parameter to the NEXT yield. Here:
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {})));
expect(gen.next().value).toEqual(delay(1000));
expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id')));
@@ -162,5 +163,67 @@ describe('RootSaga', () => {
expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' })));
expect(gen.next().value).toBeUndefined();
});
it('should reset profile if error has no processedData', () => {
const action = profileActions.saveProfile('formid', 'user1');
const gen = handleSaveProfile(action);
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
const err = new Error('oops');
const result = gen.throw(err);
expect(result.value).toEqual(put(profileActions.saveProfileReset()));
});
});
describe('handleSaveProfilePhoto', () => {
it('should save profile photo successfully', () => {
const action = profileActions.saveProfilePhoto('user1', { some: 'formdata' });
const gen = handleSaveProfilePhoto(action);
const fakePhoto = { url: 'photo.jpg' };
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.postProfilePhoto, 'user1', { some: 'formdata' }));
expect(gen.next(fakePhoto).value).toEqual(put(profileActions.saveProfilePhotoSuccess(fakePhoto)));
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
it('should reset photo state on error', () => {
const action = profileActions.saveProfilePhoto('user1', {});
const gen = handleSaveProfilePhoto(action);
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
const err = new Error('fail');
expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset()));
expect(gen.next().done).toBe(true);
});
});
describe('handleDeleteProfilePhoto', () => {
it('should delete profile photo successfully', () => {
const action = profileActions.deleteProfilePhoto('user1');
const gen = handleDeleteProfilePhoto(action);
const fakeResult = { ok: true };
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin()));
expect(gen.next().value).toEqual(call(ProfileApiService.deleteProfilePhoto, 'user1'));
expect(gen.next(fakeResult).value).toEqual(put(profileActions.deleteProfilePhotoSuccess(fakeResult)));
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoReset()));
expect(gen.next().value).toBeUndefined();
});
it('should reset photo state on error', () => {
const action = profileActions.saveProfilePhoto('user1', {});
const gen = handleSaveProfilePhoto(action);
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
const err = new Error('fail');
expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset()));
expect(gen.next().done).toBe(true);
});
});
});

View File

@@ -5,25 +5,22 @@ import {
getCountryList,
getCountryMessages,
getLanguageMessages,
} from '@edx/frontend-platform/i18n'; // eslint-disable-line
} from '@edx/frontend-platform/i18n';
export const formIdSelector = (state, props) => props.formId;
export const userAccountSelector = state => state.userAccount;
export const profileAccountSelector = state => state.profilePage.account;
export const profileDraftsSelector = state => state.profilePage.drafts;
export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy;
export const profilePreferencesSelector = state => state.profilePage.preferences;
export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates;
export const profileAccountDraftsSelector = state => state.profilePage.accountDrafts;
export const profileVisibilityDraftsSelector = state => state.profilePage.visibilityDrafts;
export const saveStateSelector = state => state.profilePage.saveState;
export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile;
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
export const accountErrorsSelector = state => state.profilePage.errors;
export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile;
export const disabledCountriesSelector = state => state.profilePage.disabledCountries;
export const countriesCodesListSelector = state => state.profilePage.countriesCodesList;
export const editableFormModeSelector = createSelector(
profileAccountSelector,
@@ -32,22 +29,11 @@ export const editableFormModeSelector = createSelector(
formIdSelector,
currentlyEditingFieldSelector,
(account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => {
// If the prop doesn't exist, that means it hasn't been set (for the current user's profile)
// or is being hidden from us (for other users' profiles)
let propExists = account[formId] != null && account[formId].length > 0;
propExists = formId === 'certificates' ? certificates.length > 0 : propExists; // overwrite for certificates
// If this isn't the current user's profile
propExists = formId === 'certificates' ? certificates.length > 0 : propExists;
if (!isAuthenticatedUserProfile) {
return 'static';
}
// the current user has no age set / under 13 ...
if (account.requiresParentalConsent) {
// then there are only two options: static or nothing.
// We use 'null' as a return value because the consumers of
// getMode render nothing at all on a mode of null.
return propExists ? 'static' : null;
}
// Otherwise, if this is the current user's profile...
if (formId === currentlyEditingField) {
return 'editing';
}
@@ -68,12 +54,10 @@ export const accountDraftsFieldSelector = createSelector(
export const visibilityDraftsFieldSelector = createSelector(
formIdSelector,
profileVisibilityDraftsSelector,
(formId, visibilityDrafts) => visibilityDrafts[formId],
profileDraftsSelector,
(formId, drafts) => drafts[`visibility${formId.charAt(0).toUpperCase() + formId.slice(1)}`],
);
// Note: Error messages are delivered from the server
// localized according to a user's account settings
export const formErrorSelector = createSelector(
accountErrorsSelector,
formIdSelector,
@@ -91,11 +75,6 @@ export const editableFormSelector = createSelector(
}),
);
// Because this selector has no input selectors, it will only be evaluated once. This is fine
// for now because we don't allow users to change the locale after page load.
// Once we DO allow this, we should create an actual action which dispatches the locale into redux,
// then we can modify this to get the locale from state rather than from getLocale() directly.
// Once we do that, this will work as expected and be re-evaluated when the locale changes.
export const localeSelector = () => getLocale();
export const countryMessagesSelector = createSelector(
localeSelector,
@@ -113,7 +92,14 @@ export const sortedLanguagesSelector = createSelector(
export const sortedCountriesSelector = createSelector(
localeSelector,
locale => getCountryList(locale),
countriesCodesListSelector,
profileAccountSelector,
(locale, countriesCodesList, profileAccount) => {
const countryList = getCountryList(locale);
const userCountry = profileAccount.country;
return countryList.filter(({ code }) => code === userCountry || countriesCodesList.find(x => x === code));
},
);
export const preferredLanguageSelector = createSelector(
@@ -131,13 +117,13 @@ export const countrySelector = createSelector(
editableFormSelector,
sortedCountriesSelector,
countryMessagesSelector,
disabledCountriesSelector,
countriesCodesListSelector,
profileAccountSelector,
(editableForm, sortedCountries, countryMessages, disabledCountries, account) => ({
(editableForm, translatedCountries, countryMessages, countriesCodesList, account) => ({
...editableForm,
sortedCountries,
translatedCountries,
countryMessages,
disabledCountries,
countriesCodesList,
committedCountry: account.country,
}),
);
@@ -162,9 +148,6 @@ export const profileImageSelector = createSelector(
: {}),
);
/**
* This is used by a saga to pull out data to process.
*/
export const handleSaveProfileSelector = createSelector(
profileDraftsSelector,
profilePreferencesSelector,
@@ -174,7 +157,6 @@ export const handleSaveProfileSelector = createSelector(
}),
);
// Reformats the social links in a platform-keyed hash.
const socialLinksByPlatformSelector = createSelector(
profileAccountSelector,
(account) => {
@@ -201,24 +183,18 @@ const draftSocialLinksByPlatformSelector = createSelector(
},
);
// Fleshes out our list of existing social links with all the other ones the user can set.
export const formSocialLinksSelector = createSelector(
socialLinksByPlatformSelector,
draftSocialLinksByPlatformSelector,
(linksByPlatform, draftLinksByPlatform) => {
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const socialLinks = [];
// For each known platform
knownPlatforms.forEach((platform) => {
// If the link is in our drafts.
if (draftLinksByPlatform[platform] !== undefined) {
// Use the draft one.
socialLinks.push(draftLinksByPlatform[platform]);
} else if (linksByPlatform[platform] !== undefined) {
// Otherwise use the real one.
socialLinks.push(linksByPlatform[platform]);
} else {
// And if it's not in either, use a stub.
socialLinks.push({
platform,
socialLink: null,
@@ -237,7 +213,6 @@ export const visibilitiesSelector = createSelector(
case 'custom':
return {
visibilityBio: preferences.visibilityBio || 'all_users',
visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'all_users',
visibilityCountry: preferences.visibilityCountry || 'all_users',
visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users',
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users',
@@ -247,7 +222,6 @@ export const visibilitiesSelector = createSelector(
case 'private':
return {
visibilityBio: 'private',
visibilityCourseCertificates: 'private',
visibilityCountry: 'private',
visibilityLevelOfEducation: 'private',
visibilityLanguageProficiencies: 'private',
@@ -256,13 +230,8 @@ export const visibilitiesSelector = createSelector(
};
case 'all_users':
default:
// All users is intended to fall through to default.
// If there is no value for accountPrivacy in perferences, that means it has not been
// explicitly set yet. The server assumes - today - that this means "all_users",
// so we emulate that here in the client.
return {
visibilityBio: 'all_users',
visibilityCourseCertificates: 'all_users',
visibilityCountry: 'all_users',
visibilityLevelOfEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
@@ -273,9 +242,6 @@ export const visibilitiesSelector = createSelector(
},
);
/**
* If there's no draft present at all (undefined), use the original committed value.
*/
function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed;
}
@@ -290,10 +256,6 @@ export const formValuesSelector = createSelector(
bio: chooseFormValue(drafts.bio, account.bio),
visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio),
courseCertificates,
visibilityCourseCertificates: chooseFormValue(
drafts.visibilityCourseCertificates,
visibilities.visibilityCourseCertificates,
),
country: chooseFormValue(drafts.country, account.country),
visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry),
levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation),
@@ -311,7 +273,7 @@ export const formValuesSelector = createSelector(
),
name: chooseFormValue(drafts.name, account.name),
visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName),
socialLinks, // Social links is calculated in its own selector, since it's complicated.
socialLinks,
visibilitySocialLinks: chooseFormValue(
drafts.visibilitySocialLinks,
visibilities.visibilitySocialLinks,
@@ -328,6 +290,7 @@ export const profilePageSelector = createSelector(
isLoadingProfileSelector,
draftSocialLinksByPlatformSelector,
accountErrorsSelector,
isAuthenticatedUserProfileSelector,
(
account,
formValues,
@@ -337,47 +300,39 @@ export const profilePageSelector = createSelector(
isLoadingProfile,
draftSocialLinksByPlatform,
errors,
isAuthenticatedUserProfile,
) => ({
// Account data we need
username: account.username,
profileImage,
requiresParentalConsent: account.requiresParentalConsent,
dateJoined: account.dateJoined,
yearOfBirth: account.yearOfBirth,
// Bio form data
bio: formValues.bio,
visibilityBio: formValues.visibilityBio,
// Certificates form data
courseCertificates: formValues.courseCertificates,
visibilityCourseCertificates: formValues.visibilityCourseCertificates,
// Country form data
country: formValues.country,
visibilityCountry: formValues.visibilityCountry,
// Education form data
levelOfEducation: formValues.levelOfEducation,
visibilityLevelOfEducation: formValues.visibilityLevelOfEducation,
// Language proficiency form data
languageProficiencies: formValues.languageProficiencies,
visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies,
// Name form data
name: formValues.name,
visibilityName: formValues.visibilityName,
// Social links form data
socialLinks: formValues.socialLinks,
visibilitySocialLinks: formValues.visibilitySocialLinks,
draftSocialLinksByPlatform,
// Other data we need
saveState,
savePhotoState,
isLoadingProfile,
photoUploadError: errors.photo || null,
isAuthenticatedUserProfile,
}),
);

View File

@@ -2,11 +2,24 @@ import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils';
import { FIELD_LABELS } from './constants';
ensureConfig(['LMS_BASE_URL'], 'Profile API service');
function processAccountData(data) {
return camelCaseObject(data);
const processedData = camelCaseObject(data);
return {
...processedData,
socialLinks: Array.isArray(processedData.socialLinks) ? processedData.socialLinks : [],
languageProficiencies: Array.isArray(processedData.languageProficiencies)
? processedData.languageProficiencies : [],
name: processedData.name || null,
bio: processedData.bio || null,
country: processedData.country || null,
levelOfEducation: processedData.levelOfEducation || null,
profileImage: processedData.profileImage || {},
yearOfBirth: processedData.yearOfBirth || null,
};
}
function processAndThrowError(error, errorDataProcessor) {
@@ -19,15 +32,12 @@ function processAndThrowError(error, errorDataProcessor) {
}
}
// GET ACCOUNT
export async function getAccount(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
// Process response data
return processAccountData(data);
}
// PATCH PROFILE
export async function patchProfile(username, params) {
const processedParams = snakeCaseObject(params);
@@ -41,12 +51,9 @@ export async function patchProfile(username, params) {
processAndThrowError(error, processAccountData);
});
// Process response data
return processAccountData(data);
}
// POST PROFILE PHOTO
export async function postProfilePhoto(username, formData) {
// eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().post(
@@ -70,8 +77,6 @@ export async function postProfilePhoto(username, formData) {
return updatedData.profileImage;
}
// DELETE PROFILE PHOTO
export async function deleteProfilePhoto(username) {
// eslint-disable-next-line no-unused-vars
const { data } = await getHttpClient().delete(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`);
@@ -85,14 +90,12 @@ export async function deleteProfilePhoto(username) {
return updatedData.profileImage;
}
// GET PREFERENCES
export async function getPreferences(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
return camelCaseObject(data);
}
// PATCH PREFERENCES
export async function patchPreferences(username, params) {
let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, {
@@ -114,8 +117,6 @@ export async function patchPreferences(username, params) {
return params; // TODO: Once the server returns the updated preferences object, return that.
}
// GET COURSE CERTIFICATES
function transformCertificateData(data) {
const transformedData = [];
data.forEach((cert) => {
@@ -147,3 +148,21 @@ export async function getCourseCertificates(username) {
return [];
}
}
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 getHttpClient().get(url);
return extractCountryList(data);
} catch (e) {
logError(e);
return [];
}
}

View File

@@ -0,0 +1,174 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import {
getAccount,
patchProfile,
postProfilePhoto,
deleteProfilePhoto,
getPreferences,
patchPreferences,
getCourseCertificates,
getCountryList,
} from './services';
import { FIELD_LABELS } from './constants';
import { camelCaseObject, snakeCaseObject, convertKeyNames } from '../utils';
// --- Mocks ---
jest.mock('@edx/frontend-platform', () => ({
ensureConfig: jest.fn(),
getConfig: jest.fn(() => ({ LMS_BASE_URL: 'http://fake-lms' })),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
jest.mock('../utils', () => ({
camelCaseObject: jest.fn((obj) => obj),
snakeCaseObject: jest.fn((obj) => obj),
convertKeyNames: jest.fn((obj) => obj),
}));
const mockHttpClient = {
get: jest.fn(),
patch: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
});
// --- Tests ---
describe('services', () => {
describe('getAccount', () => {
it('should return processed account data', async () => {
const mockData = { name: 'John Doe', socialLinks: [] };
mockHttpClient.get.mockResolvedValue({ data: mockData });
const result = await getAccount('john');
expect(result).toMatchObject(mockData);
expect(mockHttpClient.get).toHaveBeenCalledWith(
'http://fake-lms/api/user/v1/accounts/john',
);
});
});
describe('patchProfile', () => {
it('should patch and return processed data', async () => {
const mockData = { bio: 'New Bio' };
mockHttpClient.patch.mockResolvedValue({ data: mockData });
const result = await patchProfile('john', { bio: 'New Bio' });
expect(result).toMatchObject(mockData);
expect(snakeCaseObject).toHaveBeenCalledWith({ bio: 'New Bio' });
});
it('should throw processed error on failure', async () => {
const error = { response: { data: { some: 'error' } } };
mockHttpClient.patch.mockRejectedValue(error);
await expect(patchProfile('john', {})).rejects.toMatchObject(error);
});
});
describe('postProfilePhoto', () => {
it('should post photo and return updated profile image', async () => {
mockHttpClient.post.mockResolvedValue({});
mockHttpClient.get.mockResolvedValue({
data: { profileImage: { url: 'img.png' } },
});
const result = await postProfilePhoto('john', new FormData());
expect(result).toEqual({ url: 'img.png' });
});
it('should throw error if API fails', async () => {
const error = { response: { data: { error: 'fail' } } };
mockHttpClient.post.mockRejectedValue(error);
await expect(postProfilePhoto('john', new FormData())).rejects.toMatchObject(error);
});
});
describe('deleteProfilePhoto', () => {
it('should delete photo and return updated profile image', async () => {
mockHttpClient.delete.mockResolvedValue({});
mockHttpClient.get.mockResolvedValue({
data: { profileImage: { url: 'deleted.png' } },
});
const result = await deleteProfilePhoto('john');
expect(result).toEqual({ url: 'deleted.png' });
});
});
describe('getPreferences', () => {
it('should return camelCased preferences', async () => {
mockHttpClient.get.mockResolvedValue({ data: { pref: 1 } });
const result = await getPreferences('john');
expect(result).toMatchObject({ pref: 1 });
expect(camelCaseObject).toHaveBeenCalledWith({ pref: 1 });
});
});
describe('patchPreferences', () => {
it('should patch preferences and return params', async () => {
mockHttpClient.patch.mockResolvedValue({});
const params = { visibility_bio: true };
const result = await patchPreferences('john', params);
expect(result).toBe(params);
expect(snakeCaseObject).toHaveBeenCalledWith(params);
expect(convertKeyNames).toHaveBeenCalled();
});
});
describe('getCourseCertificates', () => {
it('should return transformed certificates', async () => {
mockHttpClient.get.mockResolvedValue({
data: [{ download_url: '/path', certificate_type: 'type' }],
});
const result = await getCourseCertificates('john');
expect(result[0]).toHaveProperty('downloadUrl', 'http://fake-lms/path');
});
it('should log error and return empty array on failure', async () => {
mockHttpClient.get.mockRejectedValue(new Error('fail'));
const result = await getCourseCertificates('john');
expect(result).toEqual([]);
expect(logError).toHaveBeenCalled();
});
});
describe('getCountryList', () => {
it('should extract country list', async () => {
mockHttpClient.get.mockResolvedValue({
data: {
fields: [
{ name: FIELD_LABELS.COUNTRY, options: [{ value: 'US' }, { value: 'CA' }] },
],
},
});
const result = await getCountryList();
expect(result).toEqual(['US', 'CA']);
});
it('should log error and return empty array on failure', async () => {
mockHttpClient.get.mockRejectedValue(new Error('fail'));
const result = await getCountryList();
expect(result).toEqual([]);
expect(logError).toHaveBeenCalled();
});
});
});

View File

@@ -1,149 +1,140 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import classNames from 'classnames';
import messages from './Bio.messages';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsOnMobileScreen,
useIsVisibilityEnabled,
} from '../data/hooks';
class Bio extends React.Component {
constructor(props) {
super(props);
const Bio = ({
formId,
bio,
visibilityBio,
editMode,
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isMobileView = useIsOnMobileScreen();
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
handleChange(e) {
const { name, value } = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, bio, visibilityBio, editMode, saveState, error, intl,
} = this.props;
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.bio.about.me'])}
</label>
<textarea
className="form-control"
id={formId}
name={formId}
value={bio}
onChange={this.handleChange}
/>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityBio"
saveState={saveState}
visibility={visibilityBio}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
return (
<SwitchContent
className={classNames([
isMobileView ? 'pt-40px' : 'pt-0',
])}
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<textarea
className="form-control py-10px"
id={formId}
name={formId}
value={bio}
onChange={handleChange}
/>
</form>
</div>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.bio.about.me'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityBio !== null}
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityBio"
saveState={saveState}
visibility={visibilityBio}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
<p data-hj-suppress className="lead">{bio}</p>
</>
),
empty: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.bio.about.me'])} />
<EmptyContent onClick={this.handleOpen}>
<FormattedMessage
id="profile.bio.empty"
defaultMessage="Add a short bio"
description="instructions when the user hasn't written an About Me"
/>
</EmptyContent>
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.bio.about.me'])} />
<p data-hj-suppress className="lead">{bio}</p>
</>
),
}}
/>
);
}
}
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<EditableItemHeader
content={bio}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityBio !== null && isVisibilityEnabled}
visibility={visibilityBio}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<EmptyContent onClick={handleOpen}>
<FormattedMessage
id="profile.bio.empty"
defaultMessage="Add a short bio"
description="instructions when the user hasn't written an About Me"
/>
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.bio.about.me'])}
</p>
<EditableItemHeader content={bio} />
</>
),
}}
/>
);
};
Bio.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired,
// From Selector
bio: PropTypes.string,
visibilityBio: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
};
Bio.defaultProps = {
@@ -157,4 +148,4 @@ Bio.defaultProps = {
export default connect(
editableFormSelector,
{},
)(injectIntl(Bio));
)(Bio);

View File

@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.bio.about.me': {
id: 'profile.bio.about.me',
defaultMessage: 'About Me',
defaultMessage: 'Bio',
description: 'A section of a user profile',
},
});

View File

@@ -1,231 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { connect } from 'react-redux';
import get from 'lodash.get';
import messages from './Certificates.messages';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Assets
import professionalCertificateSVG from '../assets/professional-certificate.svg';
import verifiedCertificateSVG from '../assets/verified-certificate.svg';
// Selectors
import { certificatesSelector } from '../data/selectors';
class Certificates extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const { name, value } = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
renderCertificate({
certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId,
}) {
const { intl } = this.props;
const certificateIllustration = (() => {
switch (certificateType) {
case 'professional':
case 'no-id-professional':
return professionalCertificateSVG;
case 'verified':
return verifiedCertificateSVG;
case 'honor':
case 'audit':
default:
return null;
}
})();
return (
<div key={`${modifiedDate}-${courseId}`} className="col-12 col-sm-6 d-flex align-items-stretch">
<div className="card mb-4 certificate flex-grow-1">
<div
className="certificate-type-illustration"
style={{ backgroundImage: `url(${certificateIllustration})` }}
/>
<div className="card-body d-flex flex-column">
<div className="card-title">
<p className="small mb-0">
{intl.formatMessage(get(
messages,
`profile.certificates.types.${certificateType}`,
messages['profile.certificates.types.unknown'],
))}
</p>
<h4 className="certificate-title">{courseDisplayName}</h4>
</div>
<p className="small mb-0">
<FormattedMessage
id="profile.certificate.organization.label"
defaultMessage="From"
/>
</p>
<p className="h6 mb-4">{courseOrganization}</p>
<div className="flex-grow-1" />
<p className="small mb-2">
<FormattedMessage
id="profile.certificate.completion.date.label"
defaultMessage="Completed on {date}"
values={{
date: <FormattedDate value={new Date(modifiedDate)} />,
}}
/>
</p>
<div>
<Hyperlink destination={downloadUrl} className="btn btn-outline-primary" target="_blank">
{intl.formatMessage(messages['profile.certificates.view.certificate'])}
</Hyperlink>
</div>
</div>
</div>
</div>
);
}
renderCertificates() {
if (this.props.certificates === null || this.props.certificates.length === 0) {
return (
<FormattedMessage
id="profile.no.certificates"
defaultMessage="You don't have any certificates yet."
description="displays when user has no course completion certificates"
/>
);
}
return (
<div className="row align-items-stretch">{this.props.certificates.map(certificate => this.renderCertificate(certificate))}</div>
);
}
render() {
const {
visibilityCourseCertificates, editMode, saveState, intl,
} = this.props;
return (
<SwitchContent
className="mb-4"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby="course-certificates-label">
<form onSubmit={this.handleSubmit}>
<EditableItemHeader
headingId="course-certificates-label"
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
/>
<FormControls
visibilityId="visibilityCourseCertificates"
saveState={saveState}
visibility={visibilityCourseCertificates}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
{this.renderCertificates()}
</form>
</div>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityCourseCertificates !== null}
visibility={visibilityCourseCertificates}
/>
{this.renderCertificates()}
</>
),
empty: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityCourseCertificates !== null}
visibility={visibilityCourseCertificates}
/>
{this.renderCertificates()}
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.certificates.my.certificates'])} />
{this.renderCertificates()}
</>
),
}}
/>
);
}
}
Certificates.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired,
// From Selector
certificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
visibilityCourseCertificates: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
};
Certificates.defaultProps = {
editMode: 'static',
saveState: null,
visibilityCourseCertificates: 'private',
certificates: null,
};
export default connect(
certificatesSelector,
{},
)(injectIntl(Certificates));

View File

@@ -1,31 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.certificates.my.certificates': {
id: 'profile.certificates.my.certificates',
defaultMessage: 'My Certificates',
description: 'A section of a user profile',
},
'profile.certificates.view.certificate': {
id: 'profile.certificates.view.certificate',
defaultMessage: 'View Certificate',
description: 'A call to action to view a certificate',
},
'profile.certificates.types.verified': {
id: 'profile.certificates.types.verified',
defaultMessage: 'Verified Certificate',
description: 'A type of certificate a user may have earned',
},
'profile.certificates.types.professional': {
id: 'profile.certificates.types.professional',
defaultMessage: 'Professional Certificate',
description: 'A type of certificate a user may have earned',
},
'profile.certificates.types.unknown': {
id: 'profile.certificates.types.unknown',
defaultMessage: 'Certificate',
description: 'The string to display when a certificate is of an unknown type',
},
});
export default messages;

View File

@@ -1,201 +1,152 @@
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 { Form } from '@openedx/paragon';
import messages from './Country.messages';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { countrySelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
class Country extends React.Component {
constructor(props) {
super(props);
const Country = ({
formId,
country,
visibilityCountry,
editMode,
saveState,
error,
translatedCountries,
countriesCodesList,
countryMessages,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
handleChange(e) {
const {
name,
value,
} = e.target;
this.props.changeHandler(name, value);
}
const isDisabledCountry = (countryCode) => countriesCodesList.length > 0
&& !countriesCodesList.find(code => code === countryCode);
handleSubmit(e) {
e.preventDefault();
const {
country, disabledCountries, formId, submitHandler,
} = this.props;
if (!disabledCountries.includes(country)) {
submitHandler(formId);
}
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
isDisabledCountry = (country) => {
const { disabledCountries } = this.props;
return disabledCountries.includes(country);
};
filteredCountries = (countryList) => {
const { disabledCountries, committedCountry } = this.props;
if (!disabledCountries.length) {
return countryList;
}
return countryList.filter(({ code }) => {
const isDisabled = this.isDisabledCountry(code);
const isUserCountry = code === committedCountry;
return !isDisabled || isUserCountry;
});
};
render() {
const {
formId,
country,
visibilityCountry,
editMode,
saveState,
error,
intl,
sortedCountries,
countryMessages,
} = this.props;
const filteredCountries = this.filteredCountries(sortedCountries);
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<select
data-hj-suppress
className="form-control py-10px"
type="select"
id={formId}
name={formId}
value={country}
onChange={handleChange}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.country.label'])}
</label>
<select
data-hj-suppress
className="form-control"
type="select"
id={formId}
name={formId}
value={country}
onChange={this.handleChange}
>
<option value="">&nbsp;</option>
{filteredCountries.map(({ code, name }) => (
<option key={code} value={code} disabled={this.isDisabledCountry(code)}>{name}</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityCountry"
saveState={saveState}
visibility={visibilityCountry}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</form>
</div>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.country.label'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityCountry !== null}
<option value=""> </option>
{translatedCountries.map(({ code, name }) => (
<option key={code} value={code} disabled={isDisabledCountry(code)}>
{name}
</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityCountry"
saveState={saveState}
visibility={visibilityCountry}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
<p data-hj-suppress className="h5">{countryMessages[country]}</p>
</>
),
empty: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.country.label'])}
/>
<EmptyContent onClick={this.handleOpen}>
{intl.formatMessage(messages['profile.country.empty'])}
</EmptyContent>
</>
),
static: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.country.label'])}
/>
<p data-hj-suppress className="h5">{countryMessages[country]}</p>
</>
),
}}
/>
);
}
}
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<EditableItemHeader
content={countryMessages[country]}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityCountry !== null && isVisibilityEnabled}
visibility={visibilityCountry}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<EmptyContent onClick={handleOpen}>
{intl.formatMessage(messages['profile.country.empty'])}
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.country.label'])}
</p>
<EditableItemHeader content={countryMessages[country]} />
</>
),
}}
/>
);
};
Country.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired,
// From Selector
country: PropTypes.string,
visibilityCountry: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
sortedCountries: PropTypes.arrayOf(PropTypes.shape({
translatedCountries: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})).isRequired,
disabledCountries: PropTypes.arrayOf(PropTypes.string),
countriesCodesList: PropTypes.arrayOf(PropTypes.string).isRequired,
countryMessages: PropTypes.objectOf(PropTypes.string).isRequired,
committedCountry: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
};
Country.defaultProps = {
@@ -204,11 +155,9 @@ Country.defaultProps = {
country: null,
visibilityCountry: 'private',
error: null,
disabledCountries: [],
committedCountry: '',
};
export default connect(
countrySelector,
{},
)(injectIntl(Country));
)(Country);

View File

@@ -3,12 +3,12 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.country.label': {
id: 'profile.country.label',
defaultMessage: 'Location',
defaultMessage: 'Country',
description: 'The label for a country in a user profile.',
},
'profile.country.empty': {
id: 'profile.country.empty',
defaultMessage: 'Add location',
defaultMessage: 'Add country',
description: 'The affordance to add country location to a users profile.',
},
});

View File

@@ -1,180 +1,160 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import get from 'lodash.get';
import { Form } from '@openedx/paragon';
import messages from './Education.messages';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Constants
import { EDUCATION_LEVELS } from '../data/constants';
// Selectors
import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
class Education extends React.Component {
constructor(props) {
super(props);
const Education = ({
formId,
levelOfEducation,
visibilityLevelOfEducation,
editMode,
saveState,
error,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
handleChange(e) {
const {
name,
value,
} = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, levelOfEducation, visibilityLevelOfEducation, editMode, saveState, error, intl,
} = this.props;
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<select
data-hj-suppress
className="form-control py-10px"
id={formId}
name={formId}
value={levelOfEducation}
onChange={handleChange}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.education.education'])}
</label>
<select
data-hj-suppress
className="form-control"
id={formId}
name={formId}
value={levelOfEducation}
onChange={this.handleChange}
>
<option value="">&nbsp;</option>
{EDUCATION_LEVELS.map(level => (
<option key={level} value={level}>
{intl.formatMessage(get(
messages,
`profile.education.levels.${level}`,
messages['profile.education.levels.o'],
))}
</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityLevelOfEducation"
saveState={saveState}
visibility={visibilityLevelOfEducation}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</form>
</div>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.education.education'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityLevelOfEducation !== null}
<option value=""> </option>
{EDUCATION_LEVELS.map(level => (
<option key={level} value={level}>
{intl.formatMessage(get(
messages,
`profile.education.levels.${level}`,
messages['profile.education.levels.o'],
))}
</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityLevelOfEducation"
saveState={saveState}
visibility={visibilityLevelOfEducation}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
<p data-hj-suppress className="h5">
{intl.formatMessage(get(
messages,
`profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'],
))}
</p>
</>
),
empty: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.education.education'])} />
<EmptyContent onClick={this.handleOpen}>
<FormattedMessage
id="profile.education.empty"
defaultMessage="Add education"
description="instructions when the user doesn't have their level of education set"
/>
</EmptyContent>
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.education.education'])} />
<p data-hj-suppress className="h5">
{intl.formatMessage(get(
messages,
`profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'],
))}
</p>
</>
),
}}
/>
);
}
}
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<EditableItemHeader
content={intl.formatMessage(get(
messages,
`profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'],
))}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityLevelOfEducation !== null && isVisibilityEnabled}
visibility={visibilityLevelOfEducation}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<EmptyContent onClick={handleOpen}>
<FormattedMessage
id="profile.education.empty"
defaultMessage="Add level of education"
description="instructions when the user doesn't have their level of education set"
/>
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.education.education'])}
</p>
<EditableItemHeader
content={intl.formatMessage(get(
messages,
`profile.education.levels.${levelOfEducation}`,
messages['profile.education.levels.o'],
))}
/>
</>
),
}}
/>
);
};
Education.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired,
// From Selector
levelOfEducation: PropTypes.string,
visibilityLevelOfEducation: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
};
Education.defaultProps = {
@@ -188,4 +168,4 @@ Education.defaultProps = {
export default connect(
editableFormSelector,
{},
)(injectIntl(Education));
)(Education);

View File

@@ -1,92 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import get from 'lodash.get';
// Mock Data
import mockData from '../data/mock_data';
import messages from './LearningGoal.messages';
// Components
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors';
const LearningGoal = (props) => {
let { learningGoal, editMode, visibilityLearningGoal } = props;
const { intl } = props;
if (!learningGoal) {
learningGoal = mockData.learningGoal;
}
if (!editMode || editMode === 'empty') { // editMode defaults to 'empty', not sure why yet
editMode = mockData.editMode;
}
if (!visibilityLearningGoal) {
visibilityLearningGoal = mockData.visibilityLearningGoal;
}
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])}
showVisibility={visibilityLearningGoal !== null}
visibility={visibilityLearningGoal}
/>
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])} />
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
}}
/>
);
};
LearningGoal.propTypes = {
// From Selector
learningGoal: PropTypes.oneOf(['advance_career', 'start_career', 'learn_something_new', 'something_else']),
visibilityLearningGoal: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editable', 'static']),
// i18n
intl: intlShape.isRequired,
};
LearningGoal.defaultProps = {
editMode: 'static',
learningGoal: null,
visibilityLearningGoal: 'private',
};
export default connect(
editableFormSelector,
{},
)(injectIntl(LearningGoal));

View File

@@ -1,31 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.learningGoal.learningGoal': {
id: 'profile.learningGoal.learningGoal',
defaultMessage: 'Learning Goal',
description: 'A section of a user profile that displays their current learning goal.',
},
'profile.learningGoal.options.start_career': {
id: 'profile.learningGoal.options.start_career',
defaultMessage: 'I want to start my career',
description: 'Selected by user if their goal is to start their career.',
},
'profile.learningGoal.options.advance_career': {
id: 'profile.learningGoal.options.advance_career',
defaultMessage: 'I want to advance my career',
description: 'Selected by user if their goal is to advance their career.',
},
'profile.learningGoal.options.learn_something_new': {
id: 'profile.learningGoal.options.learn_something_new',
defaultMessage: 'I want to learn something new',
description: 'Selected by user if their goal is to learn something new.',
},
'profile.learningGoal.options.something_else': {
id: 'profile.learningGoal.options.something_else',
defaultMessage: 'Something else',
description: 'Selected by user if their goal is not described by the other choices.',
},
});
export default messages;

View File

@@ -1,116 +0,0 @@
import PropTypes from 'prop-types';
import React, { useMemo } from 'react';
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import messages from '../../i18n';
import viewOwnProfileMockStore from '../__mocks__/viewOwnProfile.mockStore';
import savingEditedBioMockStore from '../__mocks__/savingEditedBio.mockStore';
import LearningGoal from './LearningGoal';
const mockStore = configureMockStore([thunk]);
// props to be passed down to LearningGoal component
const requiredLearningGoalProps = {
formId: 'learningGoal',
learningGoal: 'advance_career',
drafts: {},
visibilityLearningGoal: 'private',
editMode: 'static',
saveState: null,
error: null,
openHandler: jest.fn(),
};
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
const LearningGoalWrapper = (props) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={props.store}>
<LearningGoal {...props} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapper.defaultProps = {
store: mockStore(viewOwnProfileMockStore),
};
LearningGoalWrapper.propTypes = {
store: PropTypes.shape({}),
};
const LearningGoalWrapperWithStore = ({ store }) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={mockStore(store)}>
<LearningGoal {...requiredLearningGoalProps} formId="learningGoal" />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapperWithStore.defaultProps = {
store: mockStore(savingEditedBioMockStore),
};
LearningGoalWrapperWithStore.propTypes = {
store: PropTypes.shape({}),
};
describe('<LearningGoal />', () => {
describe('renders the current learning goal', () => {
it('renders "I want to advance my career"', () => {
render(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
expect(screen.getByText('I want to advance my career')).toBeTruthy();
});
it('renders "Something else"', () => {
requiredLearningGoalProps.learningGoal = 'something_else';
render(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
expect(screen.getByText('Something else')).toBeTruthy();
});
});
});

View File

@@ -1,147 +1,182 @@
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 { InfoOutline } from '@openedx/paragon/icons';
import { Hyperlink, OverlayTrigger, Tooltip } from '@openedx/paragon';
import messages from './Name.messages';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleChange,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
class Name extends React.Component {
constructor(props) {
super(props);
const Name = ({
formId,
name,
visibilityName,
editMode,
saveState,
changeHandler,
submitHandler,
closeHandler,
openHandler,
accountSettingsUrl,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
const handleChange = useHandleChange(changeHandler);
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
handleChange(e) {
const {
name,
value,
} = e.target;
this.props.changeHandler(name, value);
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId, name, visibilityName, editMode, saveState, intl,
} = this.props;
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<div className="form-group">
<EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
{/*
This isn't a mistake - the name field should not be editable. But if it were,
you'd find the original code got deleted in the commit which added this comment.
-djoy
TODO: Relatedly, the plumbing for editing the name field is still in place.
Once we're super sure we don't want it back, you could delete the name props and
such to fully get rid of it.
*/}
<p data-hj-suppress className="h5">{name}</p>
<small className="form-text text-muted" id={`${formId}-help-text`}>
{intl.formatMessage(messages['profile.name.details'])}
</small>
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<div className="form-group">
<div className="row m-0 pb-2.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<FormControls
visibilityId="visibilityName"
saveState={saveState}
visibility={visibilityName}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</form>
</div>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.name.full.name'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityName !== null}
<EditableItemHeader content={name} />
<h4 className="font-weight-normal">
<Hyperlink destination={accountSettingsUrl} target="_blank">
{intl.formatMessage(messages['profile.name.redirect'])}
</Hyperlink>
</h4>
</div>
<FormControls
visibilityId="visibilityName"
saveState={saveState}
visibility={visibilityName}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
<p data-hj-suppress className="h5">{name}</p>
<small className="form-text text-muted">
{intl.formatMessage(messages['profile.name.details'])}
</small>
</>
),
empty: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
<EmptyContent onClick={this.handleOpen}>
{intl.formatMessage(messages['profile.name.empty'])}
</EmptyContent>
<small className="form-text text-muted">
{intl.formatMessage(messages['profile.name.details'])}
</small>
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
<p data-hj-suppress className="h5">{name}</p>
</>
),
}}
/>
);
}
}
</form>
</div>
),
editable: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader
content={name}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityName !== null && isVisibilityEnabled}
visibility={visibilityName}
/>
</>
),
empty: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EmptyContent onClick={handleOpen}>
{intl.formatMessage(messages['profile.name.empty'])}
</EmptyContent>
</>
),
static: (
<>
<div className="row m-0 pb-1.5 align-items-center">
<p data-hj-suppress className="h5 font-weight-bold m-0">
{intl.formatMessage(messages['profile.name.full.name'])}
</p>
<OverlayTrigger
key="top"
placement="top"
overlay={(
<Tooltip variant="light" id="tooltip-top">
<p className="h5 font-weight-normal m-0 p-0">
{intl.formatMessage(messages['profile.name.tooltip'])}
</p>
</Tooltip>
)}
>
<InfoOutline className="m-0 info-icon" />
</OverlayTrigger>
</div>
<EditableItemHeader content={name} />
</>
),
}}
/>
);
};
Name.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired,
// From Selector
name: PropTypes.string,
visibilityName: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
accountSettingsUrl: PropTypes.string.isRequired,
};
Name.defaultProps = {
@@ -154,4 +189,4 @@ Name.defaultProps = {
export default connect(
editableFormSelector,
{},
)(injectIntl(Name));
)(Name);

View File

@@ -3,19 +3,24 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.name.full.name': {
id: 'profile.name.full.name',
defaultMessage: 'Full Name',
defaultMessage: 'Full name',
description: 'A section of a user profile',
},
'profile.name.details': {
id: 'profile.name.details',
defaultMessage: 'This is the name that appears in your account and on your certificates.',
description: 'Describes the area for a user to update their name.',
},
'profile.name.empty': {
id: 'profile.name.empty',
defaultMessage: 'Add name',
defaultMessage: 'Add full name',
description: 'The affordance to add a name to a users profile.',
},
'profile.name.tooltip': {
id: 'profile.name.tooltip',
defaultMessage: 'The name that is used for ID verification and that appears on your certificates',
description: 'Tooltip for the full name field.',
},
'profile.name.redirect': {
id: 'profile.name.redirect',
defaultMessage: 'Edit full name from the Accounts page',
description: 'Redirect message for editing the name from the Accounts page.',
},
});
export default messages;

View File

@@ -1,166 +1,140 @@
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 { Form } from '@openedx/paragon';
import messages from './PreferredLanguage.messages';
// Components
import FormControls from './elements/FormControls';
import EditableItemHeader from './elements/EditableItemHeader';
import EmptyContent from './elements/EmptyContent';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { preferredLanguageSelector } from '../data/selectors';
import {
useCloseOpenHandler,
useHandleSubmit,
useIsVisibilityEnabled,
} from '../data/hooks';
class PreferredLanguage extends React.Component {
constructor(props) {
super(props);
const PreferredLanguage = ({
formId,
languageProficiencies,
visibilityLanguageProficiencies,
editMode,
saveState,
error,
sortedLanguages,
languageMessages,
changeHandler,
submitHandler,
closeHandler,
openHandler,
}) => {
const isVisibilityEnabled = useIsVisibilityEnabled();
const intl = useIntl();
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleOpen = this.handleOpen.bind(this);
}
handleChange(e) {
const { name, value } = e.target;
// Restructure the data.
// We deconstruct our value prop in render() so this
// changes our data's shape back to match what came in
if (name === this.props.formId) {
if (value !== '') {
this.props.changeHandler(name, [{ code: value }]);
} else {
this.props.changeHandler(name, []);
}
} else {
this.props.changeHandler(name, value);
const handleChange = ({ target: { name, value } }) => {
let newValue = value;
if (name === formId) {
newValue = value ? [{ code: value }] : [];
}
}
changeHandler(name, newValue);
};
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
const handleSubmit = useHandleSubmit(submitHandler, formId);
const handleOpen = useCloseOpenHandler(openHandler, formId);
const handleClose = useCloseOpenHandler(closeHandler, formId);
handleClose() {
this.props.closeHandler(this.props.formId);
}
const value = languageProficiencies.length ? languageProficiencies[0].code : '';
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
formId,
languageProficiencies,
visibilityLanguageProficiencies,
editMode,
saveState,
error,
intl,
sortedLanguages,
languageMessages,
} = this.props;
const value = languageProficiencies.length ? languageProficiencies[0].code : '';
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
return (
<SwitchContent
className="pt-40px"
expression={editMode}
cases={{
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={formId}
className="m-0 pb-3"
isInvalid={error !== null}
>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<select
data-hj-suppress
id={formId}
name={formId}
className="form-control py-10px"
value={value}
onChange={handleChange}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</label>
<select
data-hj-suppress
id={formId}
name={formId}
className="form-control"
value={value}
onChange={this.handleChange}
>
<option value="">&nbsp;</option>
{sortedLanguages.map(({ code, name }) => (
<option key={code} value={code}>{name}</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityLanguageProficiencies"
saveState={saveState}
visibility={visibilityLanguageProficiencies}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
</form>
</div>
),
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibilityLanguageProficiencies !== null}
<option value=""> </option>
{sortedLanguages.map(({ code, name }) => (
<option key={code} value={code}>{name}</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
<FormControls
visibilityId="visibilityLanguageProficiencies"
saveState={saveState}
visibility={visibilityLanguageProficiencies}
cancelHandler={handleClose}
changeHandler={handleChange}
/>
<p data-hj-suppress className="h5">{languageMessages[value]}</p>
</>
),
empty: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
/>
<EmptyContent onClick={this.handleOpen}>
{intl.formatMessage(messages['profile.preferredlanguage.empty'])}
</EmptyContent>
</>
),
static: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
/>
<p data-hj-suppress className="h5">{languageMessages[value]}</p>
</>
),
}}
/>
);
}
}
</form>
</div>
),
editable: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<EditableItemHeader
content={languageMessages[value]}
showEditButton
onClickEdit={handleOpen}
showVisibility={visibilityLanguageProficiencies !== null && isVisibilityEnabled}
visibility={visibilityLanguageProficiencies}
/>
</>
),
empty: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<EmptyContent onClick={handleOpen}>
{intl.formatMessage(messages['profile.preferredlanguage.empty'])}
</EmptyContent>
</>
),
static: (
<>
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
</p>
<EditableItemHeader content={languageMessages[value]} />
</>
),
}}
/>
);
};
PreferredLanguage.propTypes = {
// It'd be nice to just set this as a defaultProps...
// except the class that comes out on the other side of react-redux's
// connect() method won't have it anymore. Static properties won't survive
// through the higher order function.
formId: PropTypes.string.isRequired,
// From Selector
languageProficiencies: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string })),
// TODO: ProfilePageSelector should supply null values
// instead of empty strings when no value exists
PropTypes.oneOf(['']),
]),
visibilityLanguageProficiencies: PropTypes.oneOf(['private', 'all_users']),
@@ -172,15 +146,10 @@ PreferredLanguage.propTypes = {
name: PropTypes.string.isRequired,
})).isRequired,
languageMessages: PropTypes.objectOf(PropTypes.string).isRequired,
// Actions
changeHandler: PropTypes.func.isRequired,
submitHandler: PropTypes.func.isRequired,
closeHandler: PropTypes.func.isRequired,
openHandler: PropTypes.func.isRequired,
// i18n
intl: intlShape.isRequired,
};
PreferredLanguage.defaultProps = {
@@ -194,4 +163,4 @@ PreferredLanguage.defaultProps = {
export default connect(
preferredLanguageSelector,
{},
)(injectIntl(PreferredLanguage));
)(PreferredLanguage);

View File

@@ -8,7 +8,7 @@ const messages = defineMessages({
},
'profile.preferredlanguage.label': {
id: 'profile.preferredlanguage.label',
defaultMessage: 'Primary Language Spoken',
defaultMessage: 'Primary language spoken',
description: 'The label for a users primary spoken language.',
},
});

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