Compare commits

...

219 Commits

Author SHA1 Message Date
Thomas Tracy
4905498132 Set variable to empty string 2020-03-16 11:39:48 -04:00
Thomas Tracy
88cae3a141 Fix 404 for phone number: 2020-03-16 11:39:14 -04:00
Thomas Tracy
b34ba9f241 Requested changes 2020-03-16 10:51:46 -04:00
Thomas Tracy
03f3007351 Requested changes 2020-03-16 10:37:35 -04:00
Thomas Tracy
c40222973c Made requested changes and additional fixes 2020-03-13 14:49:32 -04:00
Thomas Tracy
7d8e2f2f85 Add phone number field and coaching consent toggle
MB-196

Adds phone number and coaching consent toggle to the profile. Currently,
toggled off in production, until we are ready for MB learners to start
recieving coaching.
2020-03-13 14:49:31 -04:00
Renovate Bot
a9d3463619 fix(deps): update dependency @edx/frontend-component-footer to v10.0.8 2020-03-05 19:17:11 +00:00
Renovate Bot
d53c9c11a9 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.9 2020-03-05 18:08:50 +00:00
Thomas Tracy
2dd8e460b1 Add phone number field and coaching consent toggle
MB-196

Adds phone number and coaching consent toggle to the profile. Currently,
toggled off in production, until we are ready for MB learners to start
recieving coaching.
2020-03-04 17:02:20 -05:00
Renovate Bot
d326eb5892 fix(deps): update dependency @edx/frontend-component-footer to v10.0.7 2020-02-07 17:11:23 +00:00
Renovate Bot
945b14fa4b chore(deps): update dependency codecov to v3.6.5 2020-02-07 15:10:23 +00:00
Renovate Bot
92b8998b96 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.27 2020-02-05 15:14:31 +00:00
Renovate Bot
a158f8c708 chore(deps): update dependency codecov to v3.6.4 2020-02-01 03:10:33 +00:00
Renovate Bot
637375e890 chore(deps): update dependency codecov to v3.6.3 2020-01-31 14:11:31 +00:00
Renovate Bot
abe5af2870 chore(deps): update dependency @edx/frontend-build to v2.0.6 2020-01-29 21:12:53 +00:00
Renovate Bot
0495c7f6ba fix(deps): update dependency @edx/frontend-platform to v1.1.14 2020-01-28 13:13:23 +00:00
Renovate Bot
56362695dd chore(deps): update dependency codecov to v3.6.2 2020-01-23 19:14:41 +00:00
Renovate Bot
1d56ea026f fix(deps): update dependency @edx/frontend-component-header to v2.0.5 2020-01-22 18:37:44 +00:00
Renovate Bot
edb5998617 fix(deps): update dependency @edx/frontend-component-header to v2.0.4 2020-01-21 19:10:59 +00:00
Renovate Bot
b8cf476d01 fix(deps): update dependency @edx/frontend-platform to v1.1.13 2019-12-28 00:10:28 +00:00
Renovate Bot
7f5e840538 fix(deps): update dependency universal-cookie to v4.0.3 2019-12-27 22:12:08 +00:00
Renovate Bot
e5e950937b fix(deps): update dependency redux to v4.0.5 2019-12-24 03:11:17 +00:00
David Joy
95cb4c9138 Update openedx.yaml to include the repo in openedx releases. 2019-12-20 13:26:35 -05:00
Renovate Bot
aac7244aec chore(deps): update dependency enzyme-adapter-react-16 to v1.15.2 2019-12-20 00:10:57 +00:00
Renovate Bot
69f9c8faf5 fix(deps): update dependency @edx/frontend-platform to v1.1.12 2019-12-15 01:10:33 +00:00
Renovate Bot
c40ba138ce chore(deps): update dependency @edx/frontend-build to v2.0.5 2019-12-12 18:19:14 +00:00
Renovate Bot
13d71b3257 chore(deps): update dependency redux-mock-store to v1.5.4 2019-12-11 14:12:02 +00:00
Renovate Bot
2f459362ad fix(deps): update dependency @edx/frontend-component-footer to v10.0.6 2019-12-11 01:05:12 +00:00
Renovate Bot
bc706c9fe6 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.26 2019-12-11 00:13:55 +00:00
Renovate Bot
db7955b3e6 fix(deps): update dependency @edx/frontend-platform to v1.1.11 2019-12-10 22:13:41 +00:00
Renovate Bot
b59a39c4b3 fix(deps): update dependency @edx/frontend-platform to v1.1.10 2019-12-09 17:11:11 +00:00
Renovate Bot
382a68ef92 fix(deps): update dependency @edx/frontend-component-footer to v10.0.5 2019-12-06 18:15:26 +00:00
Renovate Bot
0e722e1906 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.8 2019-12-06 17:13:53 +00:00
Renovate Bot
5fb8e05e6b fix(deps): update dependency @edx/frontend-platform to v1.1.9 2019-12-05 23:14:08 +00:00
Renovate Bot
1766de1145 fix(deps): update dependency @edx/frontend-component-footer to v10.0.4 2019-12-05 00:29:36 +00:00
Renovate Bot
6edf9becb5 fix(deps): update font awesome 2019-12-04 23:24:45 +00:00
Renovate Bot
a1c74bd9b8 fix(deps): update dependency redux-saga to v1.1.3 2019-12-04 23:18:35 +00:00
Renovate Bot
0139d2db75 fix(deps): update dependency @edx/frontend-platform to v1.1.8 2019-12-04 22:16:54 +00:00
Renovate Bot
38de20b454 fix(deps): update dependency react-scrollspy to v3.4.2 2019-12-04 20:40:21 +00:00
Renovate Bot
8daddeadde fix(deps): update dependency react-redux to v7.1.3 2019-12-04 20:19:09 +00:00
Renovate Bot
74e5f2bf76 chore(deps): update dependency react-test-renderer to v16.8.6 2019-12-04 19:57:33 +00:00
Renovate Bot
172f9bce4d chore(deps): update dependency glob to v7.1.6 2019-12-04 18:43:38 +00:00
Renovate Bot
fbbadfedd1 chore(deps): update dependency @edx/frontend-build to v2.0.4 2019-12-04 18:37:57 +00:00
David Joy
adf031e264 Update renovate.json 2019-12-04 13:30:05 -05:00
Adam Butterworth
da5c6b592a fix: pin header footer (#150) 2019-12-04 11:21:56 -05:00
edX Transifex Bot
39b57ead67 fix(i18n): update translations 2019-12-04 11:18:43 -05:00
Adam Butterworth
7c24f47560 fix: update header and footer 2019-12-03 18:49:59 -05:00
edX Transifex Bot
4276442bfa fix(i18n): update translations 2019-12-03 13:54:55 -05:00
David Joy
bdad621102 fix: updating frontend-build to fix translations (#144) 2019-12-03 13:28:25 -05:00
renovate[bot]
c0be21fc3f fix(deps): update dependency @edx/frontend-component-header to v2.0.1 (#141) 2019-12-03 11:22:24 -05:00
renovate[bot]
75c1354fae fix(deps): update dependency @edx/frontend-component-footer to v10.0.1 (#142) 2019-12-03 11:12:12 -05:00
renovate[bot]
39eb2bb310 chore(deps): update dependency @edx/frontend-build to v2.0.2 (#134) 2019-12-02 15:06:50 -05:00
renovate[bot]
ec0f816b0e fix(deps): update dependency @edx/frontend-platform to v1.1.4 (#131) 2019-12-02 15:05:42 -05:00
renovate[bot]
c863e53855 chore(deps): pin dependencies (#119) 2019-12-02 10:17:02 -05:00
edX Transifex Bot
f4811efe66 fix(i18n): update translations 2019-12-01 16:08:23 -05:00
Adam Butterworth
ea37cf01dd feat: upgrade to frontend-platform (#126)
* feat: upgrade to frontend-platform

* Upgrading frontend-platform and re-generating package-lock.json

* Enabling duplicate_provider check again

* overrideHandlers.loadConfig -> handlers.config
2019-11-25 12:35:02 -05:00
David Joy
15d8836a8c fix: all account settings page elements now re-localize when the locale changes (#125)
- Ceased using i18nReducer and setLocale from frontend-i18n.
- Caused a number of dropdown menus to re-localize properly.
- Published LOCALE_CHANGED event when changing locale so that AppProvider updates the AppContext accordingly.
2019-11-20 16:54:21 -05:00
David Joy
05ea18f70d Small refactor to make updating the site language sequential (#124)
We were calling the setlang and preferences endpoints in parallel - it’s not clear whether this might create a race condition.  Instead, we now call preferences first, followed by i18n/setlang.  This matches the behavior of the django-based account settings page.
2019-11-13 14:16:20 -05:00
David Joy
cca4af7cc1 fix: adding underlines into JumpNav again (#123) 2019-10-31 12:52:13 -04:00
edX Transifex Bot
b1a5f98541 fix(i18n): update translations 2019-10-27 17:03:19 -04:00
David Joy
8020ff58b8 Adding frontend-base (#120)
* Use the new header and footer.

Note: Because we’re not fully using frontend-base yet, the header is broken.  It’ll start working once frontend-base’s App singleton is properly initialized.

* Initializing the app via App.initialize

- Removes App component
- Cleans up environment configuration - SUPPORT_URL is the only custom env variable.
- Cleans up usage of SUPPORT_URL and LOGOUT_URL to take advantage of App.config.

* Convert delete-account service to use App.

* Using App for services and cleaning up associated code.

Also pulling out the frontend-auth shim, since it was also dead and was sorta API-service like.

* Cleaning up “common” and some dead code.

- Most of it goes into account-settings for now.
- Shuffling the “utils” files around to classify them better.
- Removing unused assets

* Moving files into data subdirectory in account-settings

Including all the utils stuff.

* Moving top level reducers/sagas into a data dir

* Fix import bug with sagaUtils

* Removing connected-react-router

* Ceasing to use authentication and configuration from redux

Also removing some unnecessary test config.

* Updating redux init to default to prod.

Also fixing a bug where it wasn’t going into prod mode at all.

* Moving the duplicateTpaProvider logic out of redux

This lets us stop setting initial state on redux.

Also removing url-polyfill.

* A little cleanup.

* Remove default exports to keep the pattern the same.
2019-10-25 12:45:37 -04:00
Robert Raposa
569099e88a Merge pull request #121 from edx/robrap/cleanup-readme
clean up readme
2019-10-24 11:50:05 -04:00
Robert Raposa
90260ec263 clean up readme 2019-10-23 12:25:23 -04:00
David Joy
5ca2b801c7 Fixing newlines. 2019-10-21 12:31:17 -04:00
David Joy
149cce731d Convert to using frontend-build. (#118)
Goodbye dependencies!  Goodbye config!
2019-10-21 12:30:24 -04:00
Adam Butterworth
274af6c2e5 fix: remove youtube logo from footer (#117) 2019-10-16 14:29:24 -04:00
David Joy
9cdf40f1bb doc: update readme (#116) 2019-10-11 18:07:56 -04:00
David Joy
22e855702c node 12 and cleanup (#115)
- cleaning up .travis.yml
- bumping node-sass
2019-10-11 15:28:41 -04:00
David Joy
c566b157d9 Add favicon, run npm audit fix, and remove CSRF_TOKEN_NAME (#114)
* Adding a favicon

* Running npm audit fix

* Removing CSRF_COOKIE_NAME
2019-10-11 15:03:29 -04:00
albemarle
d124a91688 chore: use AGPLv3 instead of GPLv3 (#113) 2019-08-21 14:56:14 -04:00
edX Transifex Bot
5c578af96c fix(i18n): update translations 2019-08-13 14:18:12 -04:00
Robert Raposa
3076227249 Merge pull request #111 from edx/abutterworth/upgrade-auth
fix: upgrade frontend-auth
2019-07-30 17:07:35 -04:00
Adam Butterworth
5cb1947a69 fix: upgrade frontend-auth 2019-07-30 14:54:00 -04:00
edX Transifex Bot
39cd052a81 fix(i18n): update translations 2019-07-21 17:02:20 -04:00
Douglas Hall
bfbcdf7ea0 Send event when discover new header link clicked (#107)
Send event when discover new header link clicked
2019-06-26 13:11:56 -04:00
Douglas Hall
68928291bc Send event when discover new header link clicked 2019-06-25 17:42:02 -04:00
edX Transifex Bot
917f7cb486 fix(i18n): update translations 2019-06-16 17:07:52 -04:00
Douglas Hall
d2ff8e1eec Merge pull request #105 from edx/douglashall/ARCH-903_905
Update header links.
2019-06-12 14:22:38 -04:00
Douglas Hall
29269fcdbb Update header links. 2019-06-12 11:29:57 -04:00
Douglas Hall
ea8b435c35 Merge pull request #104 from edx/douglashall/ARCH-873_2
Fix identityAuthenticatedUser call.
2019-06-12 10:39:52 -04:00
Douglas Hall
23e6ca6bc4 Fix identityAuthenticatedUser call. 2019-06-12 10:29:00 -04:00
albemarle
23f54949e8 fix(i18n): make detect_changed_source_translations work for both push and pull jobs (#103) 2019-06-11 12:21:35 -04:00
edX Transifex Bot
f43bc5597c fix(i18n): update translations 2019-06-09 17:02:59 -04:00
David Joy
481e38c308 Bump frontend-i18n version and use its scripts. (#100) 2019-06-07 11:13:11 -04:00
Douglas Hall
0a7d449c09 Merge pull request #101 from edx/douglashall/upgrade_analytics_1_0_4
Upgrade frontend-analytics to 1.0.4.
2019-06-06 16:27:48 -04:00
Douglas Hall
2acc15eaa6 Upgrade frontend-analytics to 1.0.4. 2019-06-06 16:19:55 -04:00
Douglas Hall
92fd7c4baa Merge pull request #99 from edx/douglashall/improve_error_boundary
Render ErrorPage component via app-level error boundary.
2019-06-06 14:10:40 -04:00
Douglas Hall
0d61d09ddd Render ErrorPage component via app-level error boundary. 2019-06-06 14:00:31 -04:00
David Joy
223ea212bc Adding tests for delete account. (#98)
- comprehensive reducer tests
- UI snapshot tests for most (all?) states
2019-06-06 09:40:35 -04:00
Douglas Hall
58519e5d7f Merge pull request #97 from edx/douglashall/ARCH-904_3
Handle Google Translate DOM manipulation error scenario.
2019-06-05 15:43:23 -04:00
Douglas Hall
4132963bfb Handle Google Translate DOM manipulation error scenario. 2019-06-05 15:34:01 -04:00
David Joy
bbe2a6ab49 Get correct path to tpaProviders. (#96) 2019-06-05 13:35:54 -04:00
David Joy
defd969a80 Fix third party auth naming issues (#95)
Two problems:

- The auth providers were being added to the wrong part of the store.
- The ThirdPartyAuth component expected them to have the name “providers”, not “authProviders” - great reason to keep names consistent!
2019-06-05 13:15:44 -04:00
David Joy
d33cb658e2 Modularizing third party auth. (#93) 2019-06-05 12:25:16 -04:00
Douglas Hall
2ac911945c Merge pull request #94 from edx/douglashall/upgrade_analytics_1_0_3
Upgrade frontend-analytics to 1.0.3.
2019-06-05 11:19:07 -04:00
Douglas Hall
a7530f84aa Upgrade frontend-analytics to 1.0.3. 2019-06-05 11:13:59 -04:00
David Joy
15708b1154 Modularizing ResetPassword. (#92) 2019-06-05 11:06:56 -04:00
David Joy
c3749e9c7f Cleaning up delete account actions and action types. (#91) 2019-06-04 20:12:25 -04:00
David Joy
30914e6d6f Misc whitespace, deletions, and simplifications. (#90) 2019-06-04 17:31:19 -04:00
David Joy
78b5991c89 Modularizing DeleteAccount (#87) 2019-06-04 16:03:42 -04:00
Adam Butterworth
0ea87767af fix: async states for tpa disconnect is per provider not shared (#89) 2019-06-04 13:02:30 -06:00
Adam Butterworth
3e50222528 feat: add scrollspy to jump nav (#88) 2019-06-04 13:02:06 -06:00
edX Transifex Bot
c912f0e5d5 fix(i18n): update translations 2019-06-04 12:20:02 -04:00
albemarle
19a88de031 refactor(i18n): nicer Makefile for translation jobs (#78) 2019-06-04 12:11:19 -04:00
Douglas Hall
8c76c3657d Merge pull request #85 from edx/douglashall/ARCH-873
Upgrade frontend-analytics to 1.0.2.
2019-06-04 11:40:30 -04:00
Douglas Hall
28c5be9897 Upgrade frontend-analytics to 1.0.2. 2019-06-04 11:34:32 -04:00
David Joy
386d2ab1f3 Tidy: step 1, shuffle common components and add a utility function (#81)
* Adding getModuleState helper method.

* Light component rearranging in preparation for moving into subdirectories.
2019-06-04 09:49:59 -04:00
edX Transifex Bot
e8369ff5b7 fix(i18n): update translations 2019-06-03 16:05:34 -04:00
Adam Butterworth
d9d14202c5 feat: handle i18n rtl with postcss-rtl (#83) 2019-06-03 13:43:56 -06:00
Douglas Hall
e1e7344533 Merge pull request #82 from edx/douglashall/upgrade_frontend_auth_5_3_3
Upgrade frontend-auth to 5.3.3.
2019-06-03 15:10:04 -04:00
Douglas Hall
029813646b Upgrade frontend-auth to 5.3.3. 2019-06-03 14:14:51 -04:00
Douglas Hall
87ea759e0e Merge pull request #80 from edx/douglashall/upgrade_i18n
Upgrade frontend-i18n to 1.1.2.
2019-06-03 09:48:51 -04:00
Douglas Hall
b67083b2e7 Upgrade frontend-i18n to 1.1.2. 2019-06-03 09:43:14 -04:00
edX Transifex Bot
14ded7cc23 fix(i18n): update translations 2019-06-02 17:08:03 -04:00
Douglas Hall
543e6ccae0 Merge pull request #77 from edx/douglashall/ARCH-738
Remove render block on fetchUserAccount.
2019-05-31 16:43:48 -04:00
Douglas Hall
e6246f3233 Remove render block on fetchUserAccount. 2019-05-31 16:04:28 -04:00
Douglas Hall
c04024ef00 Merge pull request #76 from edx/douglashall/bump_footer
Bump frontend-component-footer version.
2019-05-31 14:43:30 -04:00
Douglas Hall
81e9858be1 Bump frontend-component-footer and frontend-component-site-header version. 2019-05-31 14:37:42 -04:00
albemarle
4374c60dc6 fix(i18n): tell babel-plugin-react-intl where defineMessages and friends are now being imported from (#75) 2019-05-31 08:43:08 -04:00
Adam Butterworth
70e9bb31a5 feat: add purgecss to remove unused css from production build (#73)
* feat: add purgecss to remove unused styles

* docs: add note for purgecss to readme
2019-05-30 14:32:23 -06:00
albemarle
cee06f4e25 fix(delete my account): fix problem with password managers making modal open (#74) 2019-05-30 16:00:28 -04:00
David Joy
8070ec1acf Adding formdata polyfill for IE and Edge. (#72) 2019-05-30 13:53:04 -04:00
Douglas Hall
abf0c65be7 Upgrade frontend-i18n to pull in performance improvements (#71)
Upgrade frontend-i18n to pull in performance improvements
2019-05-30 10:53:56 -04:00
Douglas Hall
a6c671e824 Upgrade frontend-i18n to pull in performance improvements 2019-05-30 10:47:04 -04:00
albemarle
ed43fe3b37 feat(delete my account): improvements in error handling for confirmation modal (#70) 2019-05-29 17:40:37 -04:00
David Joy
a026f09b63 Polyfill URL class (#69) 2019-05-29 16:29:02 -04:00
Adam Butterworth
e322ae18aa fix: increase clarity of a11y landmarks (#68) 2019-05-29 10:58:26 -06:00
Adam Butterworth
71f007b9df Add empty states and ability to delete values from select fields (#67)
* fix: add empty states and ability to delete values from select fields

* refactor: change name of isEditable method

* refactor: make managed profile conditions clearer

* refactor: be positive
2019-05-28 12:55:23 -06:00
David Joy
747fe550c7 Adding is-es5 check (#65) 2019-05-28 13:43:34 -04:00
Douglas Hall
a84516daef Merge pull request #66 from edx/douglashall/reduce_fortawesome_bundle_size
Bundle only the icons that the app uses.
2019-05-28 13:13:39 -04:00
Douglas Hall
3d3e2a2e38 Bundle only the icons that the app uses. 2019-05-28 13:08:39 -04:00
Douglas Hall
27ba7f4a65 Merge pull request #62 from edx/douglashall/reduce_bundle_size
Reduce bundle size by upgrading frontend-component-footer.
2019-05-28 09:33:38 -04:00
Douglas Hall
c3f66ab92c Reduce bundle size by upgrading frontend-component-footer. 2019-05-24 14:43:27 -04:00
David Joy
590903bcd8 Bumping paragon and a few other version numbers. (#64) 2019-05-24 13:48:49 -04:00
David Joy
a1b1d0ea60 Addressing various UX/a11y feedback (#63)
- Adding more spacing between sections.
- Adding pencil icon to “Edit” buttons.
- adding aria-level=“3” to h6 elements
2019-05-24 10:56:44 -04:00
albemarle
8d099c0048 refactor(delete my account): use selectors instead of importing configuration (#61) 2019-05-23 17:26:03 -04:00
albemarle
b81f7ae8b9 fix(delete my account): must check if any TPA providers are connected (#60) 2019-05-22 11:05:44 -04:00
albemarle
27cc4fe692 fix(delete my account): correctly expose Third Party Auth providers (#59) 2019-05-21 17:27:20 -04:00
edX Transifex Bot
3471e81988 fix(i18n): update translations 2019-05-21 17:08:18 -04:00
David Joy
8f1231192b Fix duplicate i18n messages. (#57)
Also get file perms on i18n-concat.js correct so they can be called by make.
2019-05-21 16:48:58 -04:00
David Joy
994dbb6904 Update frontend-i18n dependency. (#56) 2019-05-21 16:09:39 -04:00
David Joy
f251ac7b7e Use frontend-i18n package instead of local i18n directory. (#54)
Delete the local copy of the i18n library.  Note: the package.json path MUST be updated prior to merging this PR.
2019-05-21 15:29:21 -04:00
albemarle
f14206bdf4 fix: add order history url (#55) 2019-05-20 17:06:01 -04:00
albemarle
cae761a00d fix(routing): let routing find error page and not found page (#53) 2019-05-20 11:36:45 -04:00
albemarle
879c9a3b33 fix(delete my account): url encode (#52) 2019-05-20 10:58:27 -04:00
edX Transifex Bot
faeed0ec13 fix(i18n): update translations 2019-05-19 17:03:28 -04:00
Douglas Hall
a65efe3bb4 Merge pull request #50 from edx/douglashall/ARCH-815
Fix password reset.
2019-05-16 16:38:16 -04:00
Douglas Hall
08d079ce40 Fix password reset. 2019-05-16 16:17:37 -04:00
albemarle
feae2f635a fix(delete account): keep confirmation dialog open on error (#49) 2019-05-16 15:40:36 -04:00
Adam Stankiewicz
da3acc278d Merge pull request #42 from edx/astankiewicz/update-footer
Update @edx/frontend-component-footer, add enterpriseMarketingLink and socialLinks prop
2019-05-16 14:45:59 -04:00
Adam Stankiewicz
a705e22d62 update configuration proptypes 2019-05-16 11:31:52 -07:00
Adam Stankiewicz
1db817e1c2 use configuration prop instead of process.env 2019-05-16 11:26:59 -07:00
Adam Stankiewicz
e60c24e476 upgrade footer component 2019-05-16 11:18:18 -07:00
Adam Stankiewicz
8c3a3c284b Updates for config vars 2019-05-16 11:18:18 -07:00
Adam Stankiewicz
b90deae9f6 Update @edx/frontend-component-footer, add enterpriseMarketingLink and socialLinks prop 2019-05-16 11:18:18 -07:00
albemarle
ea55ceff05 fix: correctly expose is_active field (#48) 2019-05-16 13:56:14 -04:00
Douglas Hall
6f9e94885b Merge pull request #47 from edx/douglashall/ARCH-789
Add newrelic sourcemap upload.
2019-05-16 12:55:48 -04:00
Douglas Hall
882a13fa76 Add newrelic sourcemap upload. 2019-05-16 11:45:51 -04:00
albemarle
bc3085b141 feat: delete my account (#37) 2019-05-16 10:43:08 -04:00
Douglas Hall
f1f9c86b15 Merge pull request #46 from edx/douglashall/ARCH-729
Fix social links in footer.
2019-05-15 17:07:49 -04:00
Douglas Hall
a437f9c91a Fix social links in footer. 2019-05-15 16:32:46 -04:00
Adam Butterworth
91d7b98e08 fix: add xhr header to eliminate redirects on setlang (#45) 2019-05-15 14:26:31 -06:00
Adam Butterworth
a93dc4e3b1 feat: add jump nav (#43) 2019-05-15 13:08:53 -06:00
Douglas Hall
4ba3414bd8 Merge pull request #44 from edx/douglashall/ARCH-775
Upgrade frontend-logging
2019-05-15 14:59:37 -04:00
Douglas Hall
d4d10287d4 Upgrade frontend-logging 2019-05-15 14:54:46 -04:00
Douglas Hall
0dfd8d8558 Merge pull request #41 from edx/douglashall/ARCH-802
Display duplicate TPA provider message.
2019-05-15 11:07:37 -04:00
Douglas Hall
c60c048c24 Display duplicate TPA provider message. 2019-05-15 11:02:20 -04:00
Robert Raposa
c57213714b Merge pull request #40 from edx/robrap/ARCH-748-remove-source
fix(i18n): remove transifex_input.json
2019-05-14 15:17:41 -04:00
Robert Raposa
777193f816 fix(i18n): remove transifex_input.json
This file will be generated before being pushed to Transifex. We
will not merge it to the repo.

ARCH-748
2019-05-14 15:03:45 -04:00
Robert Raposa
8c564b3d96 Merge pull request #39 from edx/robrap/ARCH-748-update-i18n-makefile
fix(i18n): update translation make targets
2019-05-14 14:43:26 -04:00
Robert Raposa
364684b0c8 fix(i18n): update translation make targets
* Update extract_translations to also concat
* Update push_translations to actually push to Transifex

ARCH-748
2019-05-14 14:33:28 -04:00
Nimisha Asthagiri
8abb8d4e5d Merge pull request #38 from edx/arch/beta-language-banner-followup
Follow-ups on Beta Language banner
2019-05-14 10:22:21 -04:00
Nimisha Asthagiri
932d47550e Follow-ups on Beta Language banner 2019-05-13 18:41:07 -04:00
Adam Butterworth
ab62699148 fix: send social link value when empty string is supplied (#36)
* fix: send social link value when empty string is supplied

* refactor: add clarity
2019-05-13 14:37:13 -06:00
Adam Butterworth
24819bc5df fix: assignment of thirdPartyAuth third time (#35) 2019-05-13 13:11:44 -06:00
Adam Butterworth
48ba152fb7 fix: proper assignment of thirdpartauthproviders (#34) 2019-05-13 12:18:22 -06:00
Adam Butterworth
741a9632f8 fix: refetch thirdPartyAuthProviders after disconnection (#33) 2019-05-13 11:44:43 -06:00
Nimisha Asthagiri
125113a154 Merge pull request #31 from edx/arch/router-default
Default URL goes to Account Settings
2019-05-13 12:03:53 -04:00
Douglas Hall
b1a84572fc Merge pull request #32 from edx/douglashall/update_dependencies
Update some dependencies.
2019-05-13 12:03:33 -04:00
Douglas Hall
ae8c9e2893 Update some dependencies. 2019-05-13 11:59:12 -04:00
Nimisha Asthagiri
945ef15b97 Default URL goes to Account Settings 2019-05-13 11:55:20 -04:00
Adam Butterworth
9837b85dce Fix auth disconnection add error states (#30)
* fix: properly disconnect auth

* fix: add error handling and states
2019-05-13 09:49:11 -06:00
Adam Butterworth
2e83d33de2 fix: saving an account setting will no longer delete a time zone pref… (#29)
* fix: saving an account setting will no longer delete a time zone preference

* Update service.js
2019-05-13 09:44:53 -06:00
Robert Raposa
c8d84fc5ab Merge pull request #26 from edx/robrap/ARCH-785-analytics-event
ARCH-785: add analytics event edx.user.settings.viewed
2019-05-13 11:38:24 -04:00
Douglas Hall
bdd87dfaa7 Merge pull request #28 from edx/douglashall/ARCH-694
Add recovery email field.
2019-05-13 11:36:49 -04:00
Douglas Hall
563f760e16 Add recovery email field. 2019-05-13 11:31:34 -04:00
Nimisha Asthagiri
bc41541848 Merge pull request #24 from edx/arch/beta-language-banner
Support for Beta Language Banner
2019-05-13 09:28:02 -04:00
Nimisha Asthagiri
e28f17d061 Refactor BetaLanguageBanner as a separate React Component 2019-05-11 12:01:41 -04:00
Nimisha Asthagiri
58c375b83a Support for Beta Language Banner 2019-05-11 12:01:40 -04:00
Adam Butterworth
0cd9f08539 feat: add timezone groups (#27)
* feat: add groupings for time zones

* fix: delete old constants

* fix: linting
2019-05-10 14:19:06 -06:00
Douglas Hall
e3db2b72e6 Make certain field uneditable for enterprise learners. (#23)
Make certain field uneditable for enterprise learners.
2019-05-10 15:42:11 -04:00
Douglas Hall
d353d8ecf0 Make certain field uneditable for enterprise learners. 2019-05-10 15:37:40 -04:00
Adam Butterworth
cfe1be3361 fix: verify parity (#25)
* fix: style and order update

* fix: password reset sends email along with request

* fix: add best guess for help text
2019-05-10 13:17:37 -06:00
Robert Raposa
1a17ac4934 add analytics event edx.user.settings.viewed 2019-05-10 14:44:01 -04:00
Adam Butterworth
efb99185e3 feat: add time zone (#22)
* feat: add timezone

* feat: add timezone swap to one redux action set

* fix: remove commented code

* fix: add help text

* fix: add better comment

* fix: allow user to unset the timezone
2019-05-08 15:39:36 -06:00
David Joy
f3693c156c ENVIRONMENT -> NODE_ENV 2019-05-08 14:22:58 -04:00
David Joy
19c12118b3 Site language dropdown - I hope. (#20)
* Updating i18n-loader with all our languages.

* Site language dropdown done.  I hope.
2019-05-08 13:51:17 -04:00
Adam Butterworth
13046c717a feat: upgrade frontend-auth (#21) 2019-05-08 09:59:47 -06:00
Adam Butterworth
c72fd2aceb Update README.rst 2019-05-08 11:34:10 -04:00
albemarle
20b0701a64 Merge pull request #18 from edx/albemarle/messages
fix(i18n): manually update messages file
2019-05-06 17:10:34 -04:00
albemarle
d754db2ed9 fix(i18n): manually update messages file 2019-05-06 13:55:01 -04:00
albemarle
d04a33b311 Merge pull request #17 from edx/albemarle/travis
build: move i18n Travis check earlier since it is faster than linting
2019-05-06 10:20:44 -04:00
albemarle
f17101de1b build: move i18n Travis check earlier since it is faster than linting 2019-05-03 15:25:04 -04:00
David Joy
884906ac06 Upgrade frontend-app-account to latest frontend-auth and frontend-logging. (#16) 2019-05-02 16:05:31 -04:00
albemarle
02c55b6e59 Merge pull request #14 from edx/albemarle/i18n-travis
build: validate i18n
2019-05-02 14:19:44 -04:00
Adam Butterworth
f1de3d7f94 feat: improve confirmation messages on email and password (#13) 2019-05-02 14:17:44 -04:00
albemarle
617e867b01 build: validate i18n 2019-05-02 14:06:33 -04:00
albemarle
3b02893c65 Merge pull request #12 from edx/albemarle/fix-id
fix(i18n): message id needs to be unique
2019-05-02 13:57:18 -04:00
albemarle
5e9b3d9cd5 fix(i18n): message id needs to be unique 2019-05-02 13:50:25 -04:00
Adam Butterworth
12fd62ffa8 feat: add social links (#11) 2019-04-30 16:56:16 -04:00
Adam Butterworth
b7049c1567 feat: add linked accounts section (#10)
* feat: add linked accounts section

fix: change badge to small text

fix: some i18n updates

* fix: return manipulated provider data

* fix: add i18n note
2019-04-30 11:10:29 -04:00
David Joy
cbcaf3d3a6 Refactoring selectors a bit to organize/take advantage of reselect composition. (#9) 2019-04-30 09:00:43 -04:00
Adam Butterworth
49488f9386 feat: account Information Country, Education, Gender, Spoken Lang (#8)
* feat: add country select. improve handling of select inputs

* feat: add education field

* feat: add gender field

* fix: injectIntl shim should pass extra arguments

* feat: add language proficiencies select

Includes extra functionality for EditableField
2019-04-26 13:38:50 -04:00
Adam Butterworth
8bec2721b1 feat: add password reset (#7)
* refactor: reduce complexity of label styling

* feat: add password reset
2019-04-26 09:51:10 -04:00
Adam Butterworth
d4fd7acbd6 feat: refactor and add email confirmation message (#6)
* refactor: delete example service

* fix: properly send errors through to ui

* feat: add editable options to fields

* fix: make button display as a link

* fix: remove unnecessary Object.create for error

* feat: add email confirmation message and refactor to support the pattern

* refactor: move isEditing prop to form selector
2019-04-25 14:44:29 -04:00
Adam Butterworth
53aaba4f13 feat: add username (#5)
* refactor: delete example service

* fix: properly send errors through to ui

* feat: add editable options to fields

* fix: make button display as a link

* fix: remove unnecessary Object.create for error
2019-04-25 14:42:11 -04:00
Adam Butterworth
ece8b6d007 feat: add name, email, and year of birth fields
Includes redux and api pipes
2019-04-24 13:03:03 -07:00
Douglas Hall
b62f3cae70 ARCH-642 Add Order History header menu link. (#4)
ARCH-642 Add Order History header menu link.
2019-04-24 14:27:53 -04:00
Douglas Hall
eb05d5ca0a ARCH-642 Add Order History header menu link. 2019-04-24 13:34:28 -04:00
143 changed files with 14413 additions and 12550 deletions

View File

@@ -1,26 +0,0 @@
{
"presets": [
[
"env",
{
"targets": {
"browsers": ["last 2 versions", "ie 11"]
}
}
],
"babel-preset-react"
],
"plugins": [
"transform-object-rest-spread",
"transform-class-properties"
],
"env": {
"i18n": {
"plugins": [
["react-intl", {
"messagesDir": "./temp"
}]
]
}
}
}

18
.env Normal file
View File

@@ -0,0 +1,18 @@
ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=null
NODE_ENV=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=null
SUPPORT_URL=null
USER_INFO_COOKIE_NAME=null
COACHING_ENABLED=null

20
.env.development Normal file
View File

@@ -0,0 +1,20 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1997'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
MARKETING_SITE_BASE_URL='http://localhost:18000'
NODE_ENV='development'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=1997
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
# Temporary, Remove this once we are ready to release the feature.
COACHING_ENABLED=''

18
.env.test Normal file
View File

@@ -0,0 +1,18 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1997'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/login'
MARKETING_SITE_BASE_URL='http://localhost:18000'
NODE_ENV=null
ORDER_HISTORY_URL='localhost:1996/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=null
SITE_NAME='edX'
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
COACHING_ENABLED=false

View File

@@ -2,3 +2,4 @@ coverage/*
dist/
node_modules/
__mocks__/
__snapshots__/

View File

@@ -1,34 +0,0 @@
{
"extends": "eslint-config-edx",
"parser": "babel-eslint",
"rules": {
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"webpack/*.js",
"**/*.test.jsx",
"**/*.test.js"
]
}
],
// https://github.com/evcohen/eslint-plugin-jsx-a11y/issues/340#issuecomment-338424908
"jsx-a11y/anchor-is-valid": [ "error", {
"components": [ "Link" ],
"specialLink": [ "to" ]
}],
"jsx-a11y/label-has-for": [ 2, {
"components": [ "label" ],
"required": {
"some": [ "nesting", "id" ]
},
"allowChildren": false
}]
},
"env": {
"jest": true
},
"globals": {
"newrelic": false
}
}

3
.eslintrc.js Executable file
View File

@@ -0,0 +1,3 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint');

2
.gitignore vendored
View File

@@ -6,6 +6,8 @@ npm-debug.log
coverage
dist/
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
### pyenv ###
.python-version

View File

@@ -1,13 +1,15 @@
.eslintignore
.eslintrc.json
.gitignore
.travis.yml
docker-compose.yml
Dockerfile
Makefile
npm-debug.log
webpack
.tx
coverage
dist
node_modules
public
src
.dockerignore
.eslintignore
.eslintrc
.gitignore
.releaserc
.travis.yml
babel.config.js
Makefile
renovate.json

View File

@@ -1,21 +1,15 @@
language: node_js
node_js:
- lts/*
cache:
directories:
- "~/.npm"
node_js: 12
before_install:
- npm install -g npm@latest
- npm install -g greenkeeper-lockfile@1.14.0
- npm install -g npm@6
install:
- npm ci
before_script: greenkeeper-lockfile-update
after_script: greenkeeper-lockfile-upload
- npm ci
script:
- make validate-no-uncommitted-package-lock-changes
- npm run lint
- npm run test
- npm run build
- make validate-no-uncommitted-package-lock-changes
- npm run i18n_extract
- npm run lint
- npm run test
- npm run build
- npm run is-es5
after_success:
- npm run coveralls
- codecov
- codecov

149
LICENSE
View File

@@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@@ -1,36 +1,50 @@
extract_translations: ## no prerequisites so we can control order of operations
echo "We have to define this target due to tooling assumptions"
echo "Also we have to npm install using this hook b/c there's no other place for it in the current setup"
transifex_resource = frontend-app-account
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
requirements:
npm install
npm run-script i18n_extract
i18n.extract:
# Pulling display strings from .jsx files into .json files...
# Pulling display strings from .jsx files into .json files...
rm -rf $(transifex_temp)
npm run-script i18n_extract
i18n.concat:
# Gathering JSON messages into one file...
./src/i18n/i18n-concat.js ./temp/src ./src/i18n/transifex_input.json
# Gathering JSON messages into one file...
$(transifex_utils) $(transifex_temp) $(transifex_input)
extract_translations: | requirements i18n.extract i18n.concat
# Despite the name, we actually need this target to detect changes in the incoming translated message files as well.
detect_changed_source_translations:
git diff --exit-code ./src/i18n/transifex_input.json
# Checking for changed translations...
git diff --exit-code $(i18n)
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/frontend-app-account/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/frontend-app-account/source/
# push translations to Transifex, doing magic so we can include the translator comments
push_translations: | i18n.extract
# Adding translator comments...
# Fetching strings from Transifex...
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
./src/i18n/i18n-concat.js ./temp/src --comments
# Adding comments to Transifex...
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# pull translations from Transifex
pull_translations: ## must be exactly this name for edx tooling support, see ecommerce-scripts/transifex/pull.py
tx pull -f --mode reviewed --language="ar,fr,es_419,zh_CN"
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json

View File

@@ -1,22 +1,60 @@
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
frontend-app-account
=========================
====================
Please tag **@edx/arch-team** on any PRs or issues.
This is a micro-frontend application responsible for the display and updating of a user's account information. Please tag **@edx/arch-team** on any PRs or issues.
Introduction
------------
Development
-----------
React app for account settings.
Start Devstack
^^^^^^^^^^^^^^
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
- Start devstack
- Log in (http://localhost:18000/login)
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In this project, install requirements and start the development server by running:
.. code:: bash
npm install
npm start # The server will run on port 1997
Once the dev server is up visit http://localhost:1997.
Configuration and Deployment
----------------------------
This MFE is configured via node environment variables supplied at build time. See the .env file for the list of required environment variables. Example build syntax with a single environment variable:
.. code:: bash
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
For more information see the document: `Micro-frontend applications in Open
edX <https://github.com/edx/edx-developer-docs/blob/5191e800bf16cf42f25c58c58f983bdaf7f9305d/docs/micro-frontends-in-open-edx.rst>`__.
Notes
-----
The production Webpack configuration for this repo uses `Purgecss <https://www.purgecss.com/>`__ to remove unused CSS from the production css file. In ``webpack.prod.config.js`` the Purgecss plugin is configured to scan directories to determine what css selectors should remain. Currently the src/ directory is scanned along with all ``@edx/frontend-component*`` node modules and ``@edx/paragon``. **If you add and use a component in this repo that relies on HTML classes or ids for styling you must add it to the Purgecss configuration or it will be unstyled in the production build.**
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-account.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-account
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-account.svg?branch=master
:target: https://coveralls.io/github/edx/frontend-app-account
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
:target: https://codecov.io/gh/edx/frontend-app-account
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg
:target: @edx/frontend-app-account
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-account.svg
:target: @edx/frontend-app-account
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-account.svg
:target: @edx/frontend-app-account
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release

7
jest.config.js Normal file
View File

@@ -0,0 +1,7 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFiles: [
'<rootDir>/src/setupTest.js',
],
});

View File

@@ -4,3 +4,4 @@
nick: acct
oeps: {}
owner: edx/arch-team
openedx-release: {ref: master}

18476
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +1,88 @@
{
"name": "@edx/frontend-app-account",
"version": "0.1.0",
"description": "User account React app",
"version": "1.0.0-semantically-released",
"description": "User account micro-frontend for Open edX",
"author": "edX",
"license": "AGPL-3.0",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-account.git"
},
"scripts": {
"build": "NODE_ENV=production BABEL_ENV=production webpack --config=webpack/webpack.prod.config.js",
"coveralls": "cat ./coverage/lcov.info | coveralls",
"i18n_extract": "BABEL_ENV=i18n babel src --quiet > /dev/null",
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "eslint --ext .js --ext .jsx .",
"precommit": "npm run lint",
"start": "NODE_ENV=development BABEL_ENV=development webpack-dev-server --config=webpack/webpack.dev.config.js --progress",
"test": "jest --coverage --passWithNoTests",
"travis-deploy-once": "travis-deploy-once"
"lint": "fedx-scripts eslint",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"bugs": {
"url": "https://github.com/edx/frontend-app-account/issues"
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-account#readme",
"publishConfig": {
"access": "public"
},
"browserslist": [
"last 2 versions",
"ie 11"
],
"dependencies": {
"@cospired/i18n-iso-languages": "^2.0.2",
"@edx/edx-bootstrap": "^2.0.1",
"@edx/frontend-analytics": "^1.0.0",
"@edx/frontend-auth": "^4.0.0",
"@edx/frontend-component-footer": "^2.0.3",
"@edx/frontend-component-site-header": "^2.1.4",
"@edx/frontend-logging": "^1.0.2",
"@edx/paragon": "^4.1.3",
"@fortawesome/fontawesome-svg-core": "^1.2.14",
"@fortawesome/free-brands-svg-icons": "^5.7.2",
"@fortawesome/free-regular-svg-icons": "^5.7.1",
"@fortawesome/free-solid-svg-icons": "^5.7.1",
"@fortawesome/react-fontawesome": "^0.1.4",
"babel-polyfill": "^6.26.0",
"classnames": "^2.2.6",
"connected-react-router": "^5.0.1",
"email-prop-type": "^1.1.5",
"font-awesome": "^4.7.0",
"form-urlencoded": "^3.0.0",
"glob": "^7.1.3",
"history": "^4.7.2",
"i18n-iso-countries": "^3.7.8",
"iso-countries-languages": "^0.2.1",
"lodash.camelcase": "^4.3.0",
"lodash.get": "^4.4.2",
"lodash.pick": "^4.4.0",
"lodash.snakecase": "^4.1.1",
"newrelic": "^5.5.0",
"prop-types": "^15.5.10",
"react": "^16.8.3",
"react-dom": "^16.8.3",
"react-intl": "^2.8.0",
"react-redux": "^5.1.1",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-transition-group": "^2.5.3",
"redux": "^4.0.1",
"redux-devtools-extension": "^2.13.2",
"redux-logger": "^3.0.6",
"redux-saga": "^1.0.1",
"redux-thunk": "^2.2.0",
"reselect": "^4.0.0",
"universal-cookie": "^3.1.0",
"webpack-rtl-plugin": "^2.0.0"
"@edx/frontend-component-footer": "10.0.8",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-platform": "1.1.14",
"@edx/paragon": "7.1.5",
"@fortawesome/fontawesome-svg-core": "1.2.27",
"@fortawesome/free-brands-svg-icons": "5.8.2",
"@fortawesome/free-regular-svg-icons": "5.7.2",
"@fortawesome/free-solid-svg-icons": "5.8.2",
"@fortawesome/react-fontawesome": "0.1.9",
"babel-polyfill": "6.26.0",
"classnames": "2.2.6",
"font-awesome": "4.7.0",
"form-urlencoded": "4.0.1",
"formdata-polyfill": "3.0.19",
"history": "4.10.1",
"lodash.camelcase": "4.3.0",
"lodash.debounce": "4.0.8",
"lodash.findindex": "4.6.0",
"lodash.get": "4.4.2",
"lodash.isempty": "4.4.0",
"lodash.merge": "4.6.2",
"lodash.omit": "4.5.0",
"lodash.pick": "4.4.0",
"lodash.snakecase": "4.1.1",
"memoize-one": "5.1.1",
"newrelic": "5.13.1",
"prop-types": "15.7.2",
"react": "16.10.2",
"react-dom": "16.10.2",
"react-redux": "7.1.3",
"react-router": "5.1.2",
"react-router-dom": "5.1.2",
"react-router-hash-link": "1.2.2",
"react-scrollspy": "3.4.2",
"react-transition-group": "4.3.0",
"redux": "4.0.5",
"redux-devtools-extension": "2.13.8",
"redux-logger": "3.0.6",
"redux-saga": "1.1.3",
"redux-thunk": "2.3.0",
"reselect": "4.0.0",
"universal-cookie": "4.0.3"
},
"devDependencies": {
"@svgr/webpack": "^4.2.0",
"autoprefixer": "^9.4.2",
"axios-mock-adapter": "^1.15.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-jest": "^22.4.0",
"babel-loader": "^7.1.2",
"babel-plugin-react-intl": "^3.0.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"clean-webpack-plugin": "^0.1.19",
"codecov": "^3.0.0",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^0.28.9",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"fetch-mock": "^6.3.0",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-new-relic-plugin": "^1.1.0",
"html-webpack-plugin": "^3.0.3",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.0",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.7.2",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.8.6",
"@edx/frontend-build": "2.0.6",
"codecov": "3.6.5",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.2",
"es-check": "5.0.0",
"glob": "7.1.6",
"husky": "3.0.9",
"purgecss-webpack-plugin": "1.6.0",
"react-test-renderer": "16.8.6",
"reactifex": "1.1.1",
"redux-mock-store": "^1.5.3",
"sass-loader": "^6.0.6",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"url-loader": "^1.1.2",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
},
"jest": {
"testURL": "http://localhost/",
"setupFiles": [
"./src/setupTest.js"
],
"moduleNameMapper": {
"\\.svg": "<rootDir>/__mocks__/svgrMock.js",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|scss)$": "identity-obj-proxy",
"@edx/frontend-i18n(.*)$": "<rootDir>/src/i18n$1"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"src/setupTest.js",
"src/index.js",
"/tests/"
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
]
"redux-mock-store": "1.5.4"
}
}

View File

@@ -4,6 +4,7 @@
<title>Account | edX</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
</head>
<body>
<div id="root"></div>

9
renovate.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": [
"config:base"
],
"patch": {
"automerge": true
},
"rebaseStalePrs": true
}

View File

@@ -0,0 +1,545 @@
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig, history, getQueryParameters } from '@edx/frontend-platform';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import memoize from 'memoize-one';
import findIndex from 'lodash.findindex';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import {
injectIntl,
intlShape,
FormattedMessage,
getCountryList,
getLanguageList,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import messages from './AccountSettingsPage.messages';
import { fetchSettings, saveSettings, updateDraft } from './data/actions';
import { accountSettingsPageSelector } from './data/selectors';
import PageLoading from './PageLoading';
import Alert from './Alert';
import JumpNav from './JumpNav';
import DeleteAccount from './delete-account';
import EditableField from './EditableField';
import ResetPassword from './reset-password';
import ThirdPartyAuth from './third-party-auth';
import BetaLanguageBanner from './BetaLanguageBanner';
import EmailField from './EmailField';
import {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
GENDER_OPTIONS,
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import CoachingToggle from './coaching/CoachingToggle';
class AccountSettingsPage extends React.Component {
constructor(props, context) {
super(props, context);
// If there is a "duplicate_provider" query parameter, that's the backend's
// way of telling us that the provider account the user tried to link is already linked
// to another Open edX account. We use this to display a message to that effect, and remove the
// parameter from the URL.
const duplicateTpaProvider = getQueryParameters().duplicate_provider;
if (duplicateTpaProvider !== undefined) {
history.replace(history.location.pathname);
}
this.state = {
duplicateTpaProvider,
};
}
componentDidMount() {
this.props.fetchSettings();
this.props.fetchSiteLanguages();
sendTrackingLogEvent('edx.user.settings.viewed', {
page: 'account',
visibility: null,
user_id: this.context.authenticatedUser.userId,
});
}
// NOTE: We need 'locale' for the memoization in getLocalizedTimeZoneOptions. Don't remove it!
// eslint-disable-next-line no-unused-vars
getLocalizedTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions, locale) => {
const concatTimeZoneOptions = [{
label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.default']),
value: '',
}];
if (countryTimeZoneOptions.length) {
concatTimeZoneOptions.push({
label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.country']),
group: countryTimeZoneOptions,
});
}
concatTimeZoneOptions.push({
label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.all']),
group: timeZoneOptions,
});
return concatTimeZoneOptions;
});
getLocalizedOptions = memoize(locale => ({
countryOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))),
languageProficiencyOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.language_proficiencies.options.empty']),
}].concat(getLanguageList(locale).map(({ code, name }) => ({ value: code, label: name }))),
yearOfBirthOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.year_of_birth.options.empty']),
}].concat(YEAR_OF_BIRTH_OPTIONS),
educationLevelOptions: EDUCATION_LEVELS.map(key => ({
value: key,
label: this.props.intl.formatMessage(messages[`account.settings.field.education.levels.${key || 'empty'}`]),
})),
genderOptions: GENDER_OPTIONS.map(key => ({
value: key,
label: this.props.intl.formatMessage(messages[`account.settings.field.gender.options.${key || 'empty'}`]),
})),
}));
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
}
isManagedProfile() {
// Enterprise customer profiles are managed by their organizations. We determine whether
// a profile is managed or not by the presence of the profileDataManager prop.
return Boolean(this.props.profileDataManager);
}
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
};
handleSubmit = (formId, values) => {
this.props.saveSettings(formId, values);
};
renderDuplicateTpaProviderMessage() {
if (!this.state.duplicateTpaProvider) {
return null;
}
return (
<div>
<Alert className="alert alert-danger" role="alert">
<FormattedMessage
id="account.settings.message.duplicate.tpa.provider"
defaultMessage="The {provider} account you selected is already linked to another edX account."
description="alert message informing the user that the third-party account they attempted to link is already linked to another edX account"
values={{
provider: <b>{this.state.duplicateTpaProvider}</b>,
}}
/>
</Alert>
</div>
);
}
renderManagedProfileMessage() {
if (!this.isManagedProfile()) {
return null;
}
return (
<div>
<Alert className="alert alert-primary" role="alert">
<FormattedMessage
id="account.settings.message.managed.settings"
defaultMessage="Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help."
description="alert message informing the user their account data is managed by a third party"
values={{
managerTitle: <b>{this.props.profileDataManager}</b>,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
<FormattedMessage
id="account.settings.message.managed.settings.support"
defaultMessage="support"
description="website support"
/>
</Hyperlink>
),
}}
/>
</Alert>
</div>
);
}
renderEmptyStaticFieldMessage() {
if (this.isManagedProfile()) {
return this.props.intl.formatMessage(messages['account.settings.static.field.empty'], {
enterprise: this.props.profileDataManager,
});
}
return this.props.intl.formatMessage(messages['account.settings.static.field.empty.no.admin']);
}
renderSecondaryEmailField(editableFieldProps) {
if (this.props.hiddenFields.includes('secondary_email')) {
return null;
}
return (
<EmailField
name="secondary_email"
label={this.props.intl.formatMessage(messages['account.settings.field.secondary.email'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.secondary.email.empty'])}
value={this.props.formValues.secondary_email}
confirmationMessageDefinition={messages['account.settings.field.secondary.email.confirmation']}
{...editableFieldProps}
/>
);
}
renderContent() {
const editableFieldProps = {
onChange: this.handleEditableFieldChange,
onSubmit: this.handleSubmit,
};
// Memoized options lists
const {
countryOptions,
languageProficiencyOptions,
yearOfBirthOptions,
educationLevelOptions,
genderOptions,
} = this.getLocalizedOptions(this.context.locale);
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
this.props.timeZoneOptions,
this.props.countryTimeZoneOptions,
this.context.locale,
);
const hasLinkedTPA = findIndex(this.props.tpaProviders, provider => provider.connected) >= 0;
return (
<React.Fragment>
<div className="account-section" id="basic-information">
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
{this.renderManagedProfileMessage()}
<EditableField
name="username"
type="text"
value={this.props.formValues.username}
label={this.props.intl.formatMessage(messages['account.settings.field.username'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.username.help.text'])}
isEditable={false}
{...editableFieldProps}
/>
<EditableField
name="name"
type="text"
value={this.props.formValues.name}
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
this.isEditable('name') ?
this.props.intl.formatMessage(messages['account.settings.field.full.name.empty']) :
this.renderEmptyStaticFieldMessage()
}
helpText={this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])}
isEditable={this.isEditable('name')}
{...editableFieldProps}
/>
<EmailField
name="email"
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
emptyLabel={
this.isEditable('email') ?
this.props.intl.formatMessage(messages['account.settings.field.email.empty']) :
this.renderEmptyStaticFieldMessage()
}
value={this.props.formValues.email}
confirmationMessageDefinition={messages['account.settings.field.email.confirmation']}
helpText={this.props.intl.formatMessage(messages['account.settings.field.email.help.text'])}
isEditable={this.isEditable('email')}
{...editableFieldProps}
/>
{this.renderSecondaryEmailField(editableFieldProps)}
<ResetPassword email={this.props.formValues.email} />
<EditableField
name="year_of_birth"
type="select"
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={this.props.formValues.year_of_birth}
options={yearOfBirthOptions}
{...editableFieldProps}
/>
<EditableField
name="country"
type="select"
value={this.props.formValues.country}
options={countryOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
emptyLabel={
this.isEditable('country') ?
this.props.intl.formatMessage(messages['account.settings.field.country.empty']) :
this.renderEmptyStaticFieldMessage()
}
isEditable={this.isEditable('country')}
{...editableFieldProps}
/>
</div>
<div className="account-section" id="profile-information">
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.profile.information'])}
</h2>
<EditableField
name="level_of_education"
type="select"
value={this.props.formValues.level_of_education}
options={educationLevelOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.education.empty'])}
{...editableFieldProps}
/>
<EditableField
name="gender"
type="select"
value={this.props.formValues.gender}
options={genderOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.gender.empty'])}
{...editableFieldProps}
/>
<EditableField
name="language_proficiencies"
type="select"
value={this.props.formValues.language_proficiencies}
options={languageProficiencyOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
{...editableFieldProps}
/>
{getConfig().COACHING_ENABLED &&
this.props.formValues.coaching.eligible_for_coaching &&
<CoachingToggle
name="coaching"
phone_number={this.props.formValues.phone_number}
coaching={this.props.formValues.coaching}
/>
}
</div>
<div className="account-section" id="social-media">
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.social.media.description'])}</p>
<EditableField
name="social_link_linkedin"
type="text"
value={this.props.formValues.social_link_linkedin}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin.empty'])}
{...editableFieldProps}
/>
<EditableField
name="social_link_facebook"
type="text"
value={this.props.formValues.social_link_facebook}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook.empty'])}
{...editableFieldProps}
/>
<EditableField
name="social_link_twitter"
type="text"
value={this.props.formValues.social_link_twitter}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter.empty'])}
{...editableFieldProps}
/>
</div>
<div className="account-section" id="site-preferences">
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
</h2>
<BetaLanguageBanner />
<EditableField
name="siteLanguage"
type="select"
options={this.props.siteLanguageOptions}
value={this.props.siteLanguage.draft !== undefined ? this.props.siteLanguage.draft : this.context.locale}
label={this.props.intl.formatMessage(messages['account.settings.field.site.language'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.site.language.help.text'])}
{...editableFieldProps}
/>
<EditableField
name="time_zone"
type="select"
value={this.props.formValues.time_zone || ''}
options={timeZoneOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.time.zone.description'])}
{...editableFieldProps}
onSubmit={(formId, value) => {
// the endpoint will not accept an empty string. it must be null
this.handleSubmit(formId, value || null);
}}
/>
</div>
<div className="account-section" id="linked-accounts">
<h2 className="section-heading">{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts'])}</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts.description'])}</p>
<ThirdPartyAuth />
</div>
<div className="account-section" id="delete-account">
<DeleteAccount
isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA}
/>
</div>
</React.Fragment>
);
}
renderError() {
return (
<div>
{this.props.intl.formatMessage(messages['account.settings.loading.error'], {
error: this.props.loadingError,
})}
</div>
);
}
renderLoading() {
return (
<PageLoading srMessage={this.props.intl.formatMessage(messages['account.settings.loading.message'])} />
);
}
render() {
const {
loading,
loaded,
loadingError,
} = this.props;
return (
<div className="page__account-settings container-fluid py-5">
{this.renderDuplicateTpaProviderMessage()}
<h1 className="mb-4">
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
</h1>
<div>
<div className="row">
<div className="col-md-3">
<JumpNav />
</div>
<div className="col-md-9">
{loading ? this.renderLoading() : null}
{loaded ? this.renderContent() : null}
{loadingError ? this.renderError() : null}
</div>
</div>
</div>
</div>
);
}
}
AccountSettingsPage.contextType = AppContext;
AccountSettingsPage.propTypes = {
intl: intlShape.isRequired,
loading: PropTypes.bool,
loaded: PropTypes.bool,
loadingError: PropTypes.string,
// Form data
formValues: PropTypes.shape({
username: PropTypes.string,
name: PropTypes.string,
email: PropTypes.string,
secondary_email: PropTypes.string,
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
country: PropTypes.string,
level_of_education: PropTypes.string,
gender: PropTypes.string,
language_proficiencies: PropTypes.string,
phone_number: PropTypes.string,
social_link_linkedin: PropTypes.string,
social_link_facebook: PropTypes.string,
social_link_twitter: PropTypes.string,
time_zone: PropTypes.string,
coaching: PropTypes.objectOf(PropTypes.shape({
coaching_consent: PropTypes.string.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
})),
}).isRequired,
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
draft: PropTypes.string,
}),
siteLanguageOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
profileDataManager: PropTypes.string,
staticFields: PropTypes.arrayOf(PropTypes.string),
hiddenFields: PropTypes.arrayOf(PropTypes.string),
isActive: PropTypes.bool,
timeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
countryTimeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
fetchSiteLanguages: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.object),
};
AccountSettingsPage.defaultProps = {
loading: false,
loaded: false,
loadingError: null,
siteLanguage: null,
siteLanguageOptions: [],
timeZoneOptions: [],
countryTimeZoneOptions: [],
profileDataManager: null,
staticFields: [],
hiddenFields: ['secondary_email'],
tpaProviders: [],
isActive: true,
};
export default connect(accountSettingsPageSelector, {
fetchSettings,
saveSettings,
updateDraft,
fetchSiteLanguages,
})(injectIntl(AccountSettingsPage));

View File

@@ -0,0 +1,379 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.page.heading': {
id: 'account.settings.page.heading',
defaultMessage: 'Account Settings',
description: 'The page heading for the account settings page.',
},
'account.settings.loading.message': {
id: 'account.settings.loading.message',
defaultMessage: 'Loading...',
description: 'Message when data is being loaded',
},
'account.settings.loading.error': {
id: 'account.settings.loading.error',
defaultMessage: 'Error: {error}',
description: 'Message when data failed to load',
},
'account.settings.banner.beta.language': {
id: 'account.settings.banner.beta.language',
defaultMessage: 'You have set your language to {beta_language}, which is currently not fully translated. You can help us translate this language fully by joining the Transifex community and adding translations from English for learners that speak {beta_language}.',
description: 'Message when the user selects a beta language this is not yet fully translated.',
},
'account.settings.banner.beta.language.action.switch.back': {
id: 'account.settings.banner.beta.language.action.switch.back',
defaultMessage: 'Switch Back to {previous_language}',
description: 'Button on the beta language message to switch back to the previous language.',
},
'account.settings.banner.beta.language.action.help.translate': {
id: 'account.settings.banner.beta.language.action.help.translate',
defaultMessage: 'Help Translate into {beta_language}',
description: 'Button on the beta language message to help translate the beta language.',
},
'account.settings.section.account.information': {
id: 'account.settings.section.account.information',
defaultMessage: 'Account Information',
description: 'The basic account information section heading.',
},
'account.settings.section.account.information.description': {
id: 'account.settings.section.account.information.description',
defaultMessage: 'These settings include basic information about your account.',
description: 'The basic account information section heading description.',
},
'account.settings.section.profile.information': {
id: 'account.settings.section.profile.information',
defaultMessage: 'Profile Information',
description: 'The profile information section heading.',
},
'account.settings.section.site.preferences': {
id: 'account.settings.section.site.preferences',
defaultMessage: 'Site Preferences',
description: 'The site preferences section heading.',
},
'account.settings.section.linked.accounts': {
id: 'account.settings.section.linked.accounts',
defaultMessage: 'Linked Accounts',
description: 'The linked accounts section heading.',
},
'account.settings.section.linked.accounts.description': {
id: 'account.settings.section.linked.accounts.description',
defaultMessage: 'You can link your identity accounts to simplify signing in to edX.',
description: 'The linked accounts section heading description.',
},
'account.settings.field.username': {
id: 'account.settings.field.username',
defaultMessage: 'Username',
description: 'Label for account settings username field.',
},
'account.settings.field.username.help.text': {
id: 'account.settings.field.username.help.text',
defaultMessage: 'The name that identifies you on edX. You cannot change your username.',
description: 'Help text for the account settings username field.',
},
'account.settings.field.full.name': {
id: 'account.settings.field.full.name',
defaultMessage: 'Full name',
description: 'Label for account settings name field.',
},
'account.settings.field.full.name.empty': {
id: 'account.settings.field.full.name.empty',
defaultMessage: 'Add name',
description: 'Placeholder for empty account settings name field.',
},
'account.settings.field.full.name.help.text': {
id: 'account.settings.field.full.name.help.text',
defaultMessage: 'The name that is used for ID verification and that appears on your certificates.',
description: 'Help text for the account settings name field.',
},
'account.settings.field.email': {
id: 'account.settings.field.email',
defaultMessage: 'Email address (Sign in)',
description: 'Label for account settings email field.',
},
'account.settings.field.email.empty': {
id: 'account.settings.field.email.empty',
defaultMessage: 'Add email address',
description: 'Placeholder for empty account settings email field.',
},
'account.settings.field.email.confirmation': {
id: 'account.settings.field.email.confirmation',
defaultMessage: 'Weve sent a confirmation message to {value}. Click the link in the message to update your email address.',
description: 'Confirmation message for saving the account settings email field.',
},
'account.settings.field.email.help.text': {
id: 'account.settings.field.email.help.text',
defaultMessage: 'You receive messages from edX and course teams at this address.',
description: 'Help text for the account settings email field.',
},
'account.settings.field.secondary.email': {
id: 'account.settings.field.secondary.email',
defaultMessage: 'Recovery email address',
description: 'Label for account settings recovery email field.',
},
'account.settings.field.secondary.email.empty': {
id: 'account.settings.field.secondary.email.empty',
defaultMessage: 'Add a recovery email address',
description: 'Placeholder for empty account settings recovery email field.',
},
'account.settings.field.secondary.email.confirmation': {
id: 'account.settings.field.secondary.email.confirmation',
defaultMessage: 'Weve sent a confirmation message to {value}. Click the link in the message to update your recovery email address.',
description: 'Confirmation message for saving the account settings recovery email field.',
},
'account.settings.email.field.confirmation.header': {
id: 'account.settings.email.field.confirmation.header',
defaultMessage: 'One more step!',
description: 'The header of the confirmation alert saying we\'ve sent a confirmation email',
},
'account.settings.field.dob': {
id: 'account.settings.field.dob',
defaultMessage: 'Year of birth',
description: 'Label for account settings year of birth field.',
},
'account.settings.field.dob.empty': {
id: 'account.settings.field.dob.empty',
defaultMessage: 'Add year of birth',
description: 'Placeholder for empty account settings year of birth field.',
},
'account.settings.field.year_of_birth.options.empty': {
id: 'account.settings.field.year_of_birth.options.empty',
defaultMessage: 'Select a year of birth',
description: 'Option for empty value on account settings year of birth field.',
},
'account.settings.field.country': {
id: 'account.settings.field.country',
defaultMessage: 'Country',
description: 'Label for account settings country field.',
},
'account.settings.field.country.empty': {
id: 'account.settings.field.country.empty',
defaultMessage: 'Add country',
description: 'Placeholder for empty account settings country field.',
},
'account.settings.field.country.options.empty': {
id: 'account.settings.field.country.options.empty',
defaultMessage: 'Select a Country',
description: 'Option for empty value on account settings country field.',
},
'account.settings.field.site.language': {
id: 'account.settings.field.site.language',
defaultMessage: 'Site language',
description: 'Label for account settings site language field.',
},
'account.settings.field.site.language.help.text': {
id: 'account.settings.field.site.language.help.text',
defaultMessage: 'The language used throughout this site. This site is currently available in a limited number of languages.',
description: 'Help text for the site language field.',
},
'account.settings.field.education': {
id: 'account.settings.field.education',
defaultMessage: 'Education',
description: 'Label for account settings education field.',
},
'account.settings.field.education.empty': {
id: 'account.settings.field.education.empty',
defaultMessage: 'Add level of education',
description: 'Placeholder for empty account settings education field.',
},
'account.settings.field.education.levels.empty': {
id: 'account.settings.field.education.levels.empty',
defaultMessage: 'Select a level of education',
description: 'Placeholder for the education levels dropdown.',
},
'account.settings.field.education.levels.p': {
id: 'account.settings.field.education.levels.p',
defaultMessage: 'Doctorate',
description: 'Selected by the user if their highest level of education is a doctorate degree.',
},
'account.settings.field.education.levels.m': {
id: 'account.settings.field.education.levels.m',
defaultMessage: "Master's or professional degree",
description: "Selected by the user if their highest level of education is a master's or professional degree from a college or university.",
},
'account.settings.field.education.levels.b': {
id: 'account.settings.field.education.levels.b',
defaultMessage: "Bachelor's Degree",
description: "Selected by the user if their highest level of education is a four year college or university bachelor's degree.",
},
'account.settings.field.education.levels.a': {
id: 'account.settings.field.education.levels.a',
defaultMessage: "Associate's degree",
description: "Selected by the user if their highest level of education is an associate's degree. 1-2 years of college or university.",
},
'account.settings.field.education.levels.hs': {
id: 'account.settings.field.education.levels.hs',
defaultMessage: 'Secondary/high school',
description: 'Selected by the user if their highest level of education is secondary or high school. 9-12 years of education.',
},
'account.settings.field.education.levels.jhs': {
id: 'account.settings.field.education.levels.jhs',
defaultMessage: 'Junior secondary/junior high/middle school',
description: 'Selected by the user if their highest level of education is junior or middle school. 6-8 years of education.',
},
'account.settings.field.education.levels.el': {
id: 'account.settings.field.education.levels.el',
defaultMessage: 'Elementary/primary school',
description: 'Selected by the user if their highest level of education is elementary or primary school. 1-5 years of education.',
},
'account.settings.field.education.levels.none': {
id: 'account.settings.field.education.levels.none',
defaultMessage: 'No formal education',
description: 'Selected by the user to describe their education.',
},
'account.settings.field.education.levels.o': {
id: 'account.settings.field.education.levels.o',
defaultMessage: 'Other education',
description: 'Selected by the user if they have a type of education not described by the other choices.',
},
'account.settings.field.gender': {
id: 'account.settings.field.gender',
defaultMessage: 'Gender',
description: 'Label for account settings gender field.',
},
'account.settings.field.gender.empty': {
id: 'account.settings.field.gender.empty',
defaultMessage: 'Add gender',
description: 'Placeholder for empty account settings gender field.',
},
'account.settings.field.gender.options.empty': {
id: 'account.settings.field.gender.options.empty',
defaultMessage: 'Select a gender',
description: 'Placeholder for the gender options dropdown.',
},
'account.settings.field.gender.options.f': {
id: 'account.settings.field.gender.options.f',
defaultMessage: 'Female',
description: 'The label for the female gender option.',
},
'account.settings.field.gender.options.m': {
id: 'account.settings.field.gender.options.m',
defaultMessage: 'Male',
description: 'The label for the male gender option.',
},
'account.settings.field.gender.options.o': {
id: 'account.settings.field.gender.options.o',
defaultMessage: 'Other',
description: 'The label for catch-all gender option.',
},
'account.settings.field.language.proficiencies': {
id: 'account.settings.field.language.proficiencies',
defaultMessage: 'Spoken languages',
description: 'Label for account settings spoken languages field.',
},
'account.settings.field.language.proficiencies.empty': {
id: 'account.settings.field.language.proficiencies.empty',
defaultMessage: 'Add a spoken language',
description: 'Placeholder for empty account settings spoken languages field.',
},
'account.settings.field.language_proficiencies.options.empty': {
id: 'account.settings.field.language_proficiencies.options.empty',
defaultMessage: 'Select a Language',
description: 'Option for an empty value on account settings spoken languages field.',
},
'account.settings.field.time.zone': {
id: 'account.settings.field.time.zone',
defaultMessage: 'Time zone',
description: 'Label for time zone settings field.',
},
'account.settings.field.time.zone.empty': {
id: 'account.settings.field.time.zone.empty',
defaultMessage: 'Set time zone',
description: 'Placeholder for empty for time zone settings field.',
},
'account.settings.field.time.zone.description': {
id: 'account.settings.field.time.zone.description',
defaultMessage: 'Select the time zone for displaying course dates. If you do not specify a time zone, course dates, including assignment deadlines, will be displayed in your browsers local time zone.',
description: 'Description for time zone settings field.',
},
'account.settings.field.time.zone.default': {
id: 'account.settings.field.time.zone.default',
defaultMessage: 'Default (Local Time Zone)',
description: 'The default option for a time zone.',
},
'account.settings.field.time.zone.all': {
id: 'account.settings.field.time.zone.all',
defaultMessage: 'All time zones',
description: 'The label for the group of options for all time zones.',
},
'account.settings.field.time.zone.country': {
id: 'account.settings.field.time.zone.country',
defaultMessage: 'Country time zones',
description: 'The group of time zone options for a country.',
},
'account.settings.section.social.media': {
id: 'account.settings.section.social.media',
defaultMessage: 'Social Media Links',
description: 'Section header for social media links settings',
},
'account.settings.section.social.media.description': {
id: 'account.settings.section.social.media.description',
defaultMessage: 'Optionally, link your personal accounts to the social media icons on your edX profile.',
description: 'Section subheader for social media links settings',
},
'account.settings.field.social.platform.name.linkedin': {
id: 'account.settings.field.social.platform.name.linkedin',
defaultMessage: 'LinkedIn',
description: 'Label for LinkedIn',
},
'account.settings.field.social.platform.name.linkedin.empty': {
id: 'account.settings.field.social.platform.name.linkedin.empty',
defaultMessage: 'Add LinkedIn profile',
description: 'Placeholder for an empty LinkedIn field',
},
'account.settings.jump.nav.delete.account': {
id: 'account.settings.jump.nav.delete.account',
defaultMessage: 'Delete My Account',
description: 'Header for the user account deletion area',
},
'account.settings.field.social.platform.name.twitter': {
id: 'account.settings.field.social.platform.name.twitter',
defaultMessage: 'Twitter',
description: 'Label for Twitter',
},
'account.settings.field.social.platform.name.twitter.empty': {
id: 'account.settings.field.social.platform.name.twitter.empty',
defaultMessage: 'Add Twitter profile',
description: 'Placeholder for an empty Twitter field',
},
'account.settings.field.social.platform.name.facebook': {
id: 'account.settings.field.social.platform.name.facebook',
defaultMessage: 'Facebook',
description: 'Label for Facebook',
},
'account.settings.field.social.platform.name.facebook.empty': {
id: 'account.settings.field.social.platform.name.facebook.empty',
defaultMessage: 'Add Facebook profile',
description: 'Placeholder for an empty Facebook field',
},
'account.settings.editable.field.action.save': {
id: 'account.settings.editable.field.action.save',
defaultMessage: 'Save',
description: 'The save button on an editable field',
},
'account.settings.editable.field.action.cancel': {
id: 'account.settings.editable.field.action.cancel',
defaultMessage: 'Cancel',
description: 'The cancel button on an editable field',
},
'account.settings.editable.field.action.edit': {
id: 'account.settings.editable.field.action.edit',
defaultMessage: 'Edit',
description: 'The edit button on an editable field',
},
'account.settings.static.field.empty': {
id: 'account.settings.static.field.empty',
defaultMessage: 'No value set. Contact your {enterprise} administrator to make changes.',
description: 'The placeholder for an empty but uneditable field',
},
'account.settings.static.field.empty.no.admin': {
id: 'account.settings.static.field.empty.no.admin',
defaultMessage: 'No value set.',
description: 'The placeholder for an empty but uneditable field when there is no administrator',
},
});
export default messages;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
function Alert(props) {
return (
<div className={classNames('alert d-flex align-items-start', props.className)}>
<div>
{props.icon}
</div>
<div>
{props.children}
</div>
</div>
);
}
Alert.propTypes = {
className: PropTypes.string,
icon: PropTypes.node,
children: PropTypes.node,
};
Alert.defaultProps = {
className: undefined,
icon: undefined,
children: undefined,
};
export default Alert;

View File

@@ -0,0 +1,116 @@
import React from 'react';
import PropTypes from 'prop-types';
import { AppContext } from '@edx/frontend-platform/react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { connect } from 'react-redux';
import { Button, Hyperlink } from '@edx/paragon';
import { betaLanguageBannerSelector } from './data/selectors';
import messages from './AccountSettingsPage.messages';
import { saveSettings } from './data/actions';
import { TRANSIFEX_LANGUAGE_BASE_URL } from './data/constants';
import Alert from './Alert';
class BetaLanguageBanner extends React.Component {
getSiteLanguageEntry(languageCode) {
return this.props.siteLanguageList.filter(l => l.code === languageCode)[0];
}
/**
* Returns a link to the Transifex URL where contributors can provide translations.
* This code is tightly coupled to how Transifex chooses to design its URLs.
*/
getTransifexLink(languageCode) {
return TRANSIFEX_LANGUAGE_BASE_URL + this.getTransifexURLPath(languageCode);
}
/**
* Returns the URL path that Transifex chooses to use for its language sub-pages.
*
* For extended language codes, it returns the 2nd half capitalized, replacing
* hyphen (-) with underscore (_).
* example: pt-br -> pt_BR
*
* For short language codes, it returns the code as is.
* example: fr -> fr
*/
getTransifexURLPath(languageCode) {
const tokenizedCode = languageCode.split('-');
if (tokenizedCode.length > 1) {
return `${tokenizedCode[0]}_${tokenizedCode[1].toUpperCase()}`;
}
return tokenizedCode[0];
}
handleRevertLanguage = () => {
const previousSiteLanguage = this.props.siteLanguage.previousValue;
this.props.saveSettings('siteLanguage', previousSiteLanguage);
};
render() {
const savedLanguage = this.getSiteLanguageEntry(this.context.locale);
const isSavedLanguageReleased = savedLanguage.released === true;
const noPreviousLanguageSet = this.props.siteLanguage.previousValue === null;
if (isSavedLanguageReleased || noPreviousLanguageSet) {
return null;
}
const previousLanguage = this.getSiteLanguageEntry(this.props.siteLanguage.previousValue);
return (
<div>
<Alert className="beta_language_alert alert alert-warning" role="alert">
<p>
{this.props.intl.formatMessage(messages['account.settings.banner.beta.language'], {
beta_language: savedLanguage.name,
})}
</p>
<div>
<Button onClick={this.handleRevertLanguage} className="btn btn-primary mr-2">
{this.props.intl.formatMessage(
messages['account.settings.banner.beta.language.action.switch.back'],
{ previous_language: previousLanguage.name },
)}
</Button>
<Hyperlink
destination={this.getTransifexLink(savedLanguage.code)}
className="btn btn-outline-secondary"
target="_blank"
>
{this.props.intl.formatMessage(
messages['account.settings.banner.beta.language.action.help.translate'],
{ beta_language: savedLanguage.name },
)}
</Hyperlink>
</div>
</Alert>
</div>
);
}
}
BetaLanguageBanner.contextType = AppContext;
BetaLanguageBanner.propTypes = {
intl: intlShape.isRequired,
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
draft: PropTypes.string,
}),
siteLanguageList: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
code: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
released: PropTypes.bool,
})).isRequired,
saveSettings: PropTypes.func.isRequired,
};
BetaLanguageBanner.defaultProps = {
siteLanguage: null,
};
export default connect(
betaLanguageBannerSelector,
{
saveSettings,
},
)(injectIntl(BetaLanguageBanner));

View File

@@ -0,0 +1,205 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Input, StatefulButton, ValidationFormGroup } from '@edx/paragon';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
function EditableField(props) {
const {
name,
label,
emptyLabel,
type,
value,
options,
saveState,
error,
confirmationMessageDefinition,
confirmationValue,
helpText,
onEdit,
onCancel,
onSubmit,
onChange,
isEditing,
isEditable,
intl,
...others
} = props;
const id = `field-${name}`;
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(name, new FormData(e.target).get(name));
};
const handleChange = (e) => {
onChange(name, e.target.value);
};
const handleEdit = () => {
onEdit(name);
};
const handleCancel = () => {
onCancel(name);
};
const renderEmptyLabel = () => {
if (isEditable) {
return <Button onClick={handleEdit} className="btn-link p-0">{emptyLabel}</Button>;
}
return <span className="text-muted">{emptyLabel}</span>;
};
const renderValue = (rawValue) => {
if (!rawValue) return renderEmptyLabel();
if (options) {
// Use == instead of === to prevent issues when HTML casts numbers as strings
// eslint-disable-next-line eqeqeq
const selectedOption = options.find(option => option.value == rawValue);
if (selectedOption) return selectedOption.label;
}
return rawValue;
};
const renderConfirmationMessage = () => {
if (!confirmationMessageDefinition || !confirmationValue) return null;
return intl.formatMessage(confirmationMessageDefinition, {
value: confirmationValue,
});
};
return (
<SwitchContent
expression={isEditing ? 'editing' : 'default'}
cases={{
editing: (
<form onSubmit={handleSubmit}>
<ValidationFormGroup
for={id}
invalid={error != null}
invalidMessage={error}
helpText={helpText}
>
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
name={name}
id={id}
type={type}
value={value}
onChange={handleChange}
options={options}
{...others}
/>
</ValidationFormGroup>
<p>
<StatefulButton
type="submit"
className="btn-primary mr-2"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (saveState === 'pending') e.preventDefault();
}}
disabledStates={[]}
/>
<Button
onClick={handleCancel}
className="btn-outline-primary"
>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
),
default: (
<div className="form-group">
<div className="d-flex align-items-start">
<h6 aria-level="3">{label}</h6>
{isEditable ? (
<Button onClick={handleEdit} className="ml-3 btn-link">
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
</Button>
) : null}
</div>
<p>{renderValue(value)}</p>
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
</div>
),
}}
/>
);
}
EditableField.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
emptyLabel: PropTypes.node,
type: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
options: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
error: PropTypes.string,
confirmationMessageDefinition: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
description: PropTypes.string,
}),
confirmationValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
helpText: PropTypes.node,
onEdit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
intl: intlShape.isRequired,
};
EditableField.defaultProps = {
value: undefined,
options: undefined,
saveState: undefined,
label: undefined,
emptyLabel: undefined,
error: undefined,
confirmationMessageDefinition: undefined,
confirmationValue: undefined,
helpText: undefined,
isEditing: false,
isEditable: true,
};
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(injectIntl(EditableField));

View File

@@ -0,0 +1,209 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, StatefulButton, Input, ValidationFormGroup } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import Alert from './Alert';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
function EmailField(props) {
const {
name,
label,
emptyLabel,
value,
saveState,
error,
confirmationMessageDefinition,
confirmationValue,
helpText,
onEdit,
onCancel,
onSubmit,
onChange,
isEditing,
isEditable,
intl,
} = props;
const id = `field-${name}`;
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(name, new FormData(e.target).get(name));
};
const handleChange = (e) => {
onChange(name, e.target.value);
};
const handleEdit = () => {
onEdit(name);
};
const handleCancel = () => {
onCancel(name);
};
const renderConfirmationMessage = () => {
if (!confirmationMessageDefinition || !confirmationValue) return null;
return (
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2 h6" icon={faExclamationTriangle} />}
>
<h6 aria-level="3">
{intl.formatMessage(messages['account.settings.email.field.confirmation.header'])}
</h6>
{intl.formatMessage(confirmationMessageDefinition, { value: confirmationValue })}
</Alert>
);
};
const renderConfirmationValue = () => (
<span>
{confirmationValue}
<span className="ml-3 text-muted small">
<FormattedMessage
id="account.settings.email.field.confirmation.header"
defaultMessage="Pending confirmation"
description="The label next to a new pending email address"
/>
</span>
</span>
);
const renderEmptyLabel = () => {
if (isEditable) {
return <Button onClick={handleEdit} className="btn-link p-0">{emptyLabel}</Button>;
}
return <span className="text-muted">{emptyLabel}</span>;
};
const renderValue = () => {
if (confirmationValue) return renderConfirmationValue();
return value || renderEmptyLabel();
};
return (
<SwitchContent
expression={isEditing ? 'editing' : 'default'}
cases={{
editing: (
<form onSubmit={handleSubmit}>
<ValidationFormGroup
for={id}
invalid={error != null}
invalidMessage={error}
helpText={helpText}
>
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
name={name}
id={id}
type="email"
value={value}
onChange={handleChange}
/>
</ValidationFormGroup>
<p>
<StatefulButton
type="submit"
className="btn-primary mr-2"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (saveState === 'pending') e.preventDefault();
}}
disabledStates={[]}
/>
<Button
onClick={handleCancel}
className="btn-outline-primary"
>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
),
default: (
<div className="form-group">
<div className="d-flex align-items-start">
<h6 aria-level="3">{label}</h6>
{isEditable ? (
<Button onClick={handleEdit} className="ml-3 btn-link">
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
</Button>
) : null}
</div>
<p>{renderValue()}</p>
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
</div>
),
}}
/>
);
}
EmailField.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
emptyLabel: PropTypes.node,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
error: PropTypes.string,
confirmationMessageDefinition: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
description: PropTypes.string,
}),
confirmationValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
helpText: PropTypes.node,
onEdit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
intl: intlShape.isRequired,
};
EmailField.defaultProps = {
value: undefined,
saveState: undefined,
label: undefined,
emptyLabel: undefined,
error: undefined,
confirmationMessageDefinition: undefined,
confirmationValue: undefined,
helpText: undefined,
isEditing: false,
isEditable: true,
};
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(injectIntl(EmailField));

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import messages from './AccountSettingsPage.messages';
function JumpNav({ intl }) {
return (
<div className="jump-nav">
<Scrollspy
items={[
'basic-information',
'profile-information',
'social-media',
'site-preferences',
'linked-accounts',
'delete-account',
]}
className="list-unstyled"
currentClassName="font-weight-bold"
>
<li>
<NavHashLink to="#basic-information">
{intl.formatMessage(messages['account.settings.section.account.information'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#profile-information">
{intl.formatMessage(messages['account.settings.section.profile.information'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#social-media">
{intl.formatMessage(messages['account.settings.section.social.media'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#site-preferences">
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#linked-accounts">
{intl.formatMessage(messages['account.settings.section.linked.accounts'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#delete-account">
{intl.formatMessage(messages['account.settings.jump.nav.delete.account'])}
</NavHashLink>
</li>
</Scrollspy>
</div>
);
}
JumpNav.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(JumpNav);

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
export default function NotFoundPage() {
return (

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TransitionReplace } from '@edx/paragon';
const onChildExit = (htmlNode) => {
// If the leaving child has focus, take control and redirect it
if (htmlNode.contains(document.activeElement)) {
// Get the newly entering sibling.
// It's the previousSibling, but not for any explicit reason. So checking for both.
const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling;
// There's no replacement, do nothing.
if (!enteringChild) return;
// Get all the focusable elements in the entering child and focus the first one
const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusableElements.length) {
focusableElements[0].focus();
}
}
};
function SwitchContent({ expression, cases, className }) {
const getContent = (caseKey) => {
if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') {
return getContent(cases[caseKey]);
}
return React.cloneElement(cases[caseKey], { key: caseKey });
} else if (cases.default) {
if (typeof cases.default === 'string') {
return getContent(cases.default);
}
React.cloneElement(cases.default, { key: 'default' });
}
return null;
};
return (
<TransitionReplace
className={className}
onChildExit={onChildExit}
>
{getContent(expression)}
</TransitionReplace>
);
}
SwitchContent.propTypes = {
expression: PropTypes.string,
cases: PropTypes.objectOf(PropTypes.node).isRequired,
className: PropTypes.string,
};
SwitchContent.defaultProps = {
expression: null,
className: null,
};
export default SwitchContent;

View File

@@ -0,0 +1,47 @@
.page__account-settings {
.form-group {
margin-bottom: 1.5rem;
}
h6, .h6 {
margin-bottom: .25rem;
}
.btn-link {
line-height: 1.2;
border: none;
padding: 0;
display: inline-block;
}
.jump-nav {
@media (min-width: map-get($grid-breakpoints, "sm")) {
padding-top: 1rem;
position: sticky;
top: 1rem;
}
li {
margin-bottom: .5rem;
a {
text-decoration: underline;
}
}
}
.section-heading {
@extend .h4;
margin-bottom: map-get($spacers, 3);
}
.account-section {
// These properties together will shift the hashlink position
margin-bottom: map-get($spacers, 5);
padding-top: 1rem;
}
.custom-switch {
padding: 0;
max-width: 500px;
.custom-control-label {
left: 2.25rem;
line-height: 1.6rem;
}
}
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ValidationFormGroup, Input } from '@edx/paragon';
import messages from './CoachingToggle.messages';
import { editableFieldSelector } from '../data/selectors';
import { saveSettings, updateDraft } from '../data/actions';
import EditableField from '../EditableField';
const CoatchingToggle = props => (
<>
<EditableField
name="phone_number"
type="text"
value={props.phone_number}
label={props.intl.formatMessage(messages['account.settings.field.phone_number'])}
emptyLabel={props.intl.formatMessage(messages['account.settings.field.phone_number.empty'])}
onChange={props.updateDraft}
onSubmit={props.saveSettings}
/>
<ValidationFormGroup
for="coachingConsent"
helpText={props.intl.formatMessage(messages['account.settings.field.coaching_consent.tooltip'])}
invalid={!!props.error}
invalidMessage={props.intl.formatMessage(messages['account.settings.field.coaching_consent.error'])}
className="custom-control custom-switch"
>
<Input
name={props.name}
className="custom-control-input"
disabled={props.saveState === 'pending'}
type="checkbox"
id="coachingConsent"
checked={props.coaching.coaching_consent}
value={props.coaching.coaching_consent}
onChange={async (e) => {
const { name } = e.target;
const value = {
...props.coaching,
phone_number: props.phone_number,
coaching_consent: e.target.checked,
};
props.saveSettings(name, value);
}}
/>
<label className="custom-control-label" htmlFor="coachingConsent">{props.intl.formatMessage(messages['account.settings.field.coaching_consent'])}</label>
</ValidationFormGroup>
</>
);
CoatchingToggle.defaultProps = {
phone_number: '',
error: '',
};
CoatchingToggle.propTypes = {
name: PropTypes.string.isRequired,
error: PropTypes.string,
coaching: PropTypes.objectOf(PropTypes.shape({
coaching_consent: PropTypes.string.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
})).isRequired,
saveState: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
intl: intlShape.isRequired,
phone_number: PropTypes.string,
};
export default connect(editableFieldSelector, {
saveSettings,
updateDraft,
})(injectIntl(CoatchingToggle));

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.field.phone_number': {
id: 'account.settings.field.phone_number',
defaultMessage: 'Phone Number',
description: 'The label for a phone numbers setting in the user profile',
},
'account.settings.field.phone_number.empty': {
id: 'account.settings.field.phone_number.empty',
defaultMessage: 'Add a phone number',
description: 'placeholder for a profiles empty phone number field',
},
'account.settings.field.coaching_consent': {
id: 'account.settings.field.coaching_consent',
defaultMessage: 'Coaching consent',
description: 'The label for the coaching consent setting in the user profile',
},
'account.settings.field.coaching_consent.tooltip': {
id: 'account.settings.field.coaching_consent.tooltip',
defaultMessage: 'MicroBachelors programs include text message based coaching that helps you pair educational experiences with your career goals through one-on-one advice. Coaching services are included at no additional cost, and are available in English and Spanish languages. Standard messaging rates apply. Text STOP at anytime to opt-out of messages.',
description: 'A tooltip explaining what coaching is and who it is for',
},
'account.settings.field.coaching_consent.error': {
id: 'account.settings.field.coaching_consent.error',
defaultMessage: 'A valid US phone number is required to opt into coaching',
description: 'An error message that displays when a user attempts to consent to coaching without first providing a phone number in their profile',
},
});
export default messages;

View File

@@ -0,0 +1,36 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
/**
* get all settings related to the coaching plugin. Settings used
* by Microbachelors students.
* @param {Number} userId users are identified in the api by LMS id
*/
export async function getCoachingPreferences(userId) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`);
return data;
}
/**
* patch all of the settings related to coaching.
* @param {Number} userId users are identified in the api by LMS id
* @param {Object} commitValues { coaching }
*/
export async function patchCoachingPreferences(userId, commitValues) {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`;
const { coaching } = commitValues;
coaching.user = userId;
await getAuthenticatedHttpClient()
.patch(requestUrl, coaching)
.catch((error) => {
const apiError = Object.create(error);
apiError.fieldErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
// eslint-disable-next-line prefer-destructuring
apiError.fieldErrors.coaching = apiError.fieldErrors.phone_number[0];
delete apiError.fieldErrors.phone_number;
throw apiError;
});
return commitValues;
}

View File

@@ -0,0 +1,112 @@
import { AsyncActionType } from './utils';
export const FETCH_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_SETTINGS');
export const SAVE_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_SETTINGS');
export const FETCH_TIME_ZONES = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_TIME_ZONES');
export const SAVE_PREVIOUS_SITE_LANGUAGE = 'SAVE_PREVIOUS_SITE_LANGUAGE';
export const OPEN_FORM = 'OPEN_FORM';
export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
// FETCH SETTINGS ACTIONS
export const fetchSettings = () => ({
type: FETCH_SETTINGS.BASE,
});
export const fetchSettingsBegin = () => ({
type: FETCH_SETTINGS.BEGIN,
});
export const fetchSettingsSuccess = ({
values,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
}) => ({
type: FETCH_SETTINGS.SUCCESS,
payload: {
values,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
},
});
export const fetchSettingsFailure = error => ({
type: FETCH_SETTINGS.FAILURE,
payload: { error },
});
export const fetchSettingsReset = () => ({
type: FETCH_SETTINGS.RESET,
});
// FORM STATE ACTIONS
export const openForm = formId => ({
type: OPEN_FORM,
payload: { formId },
});
export const closeForm = formId => ({
type: CLOSE_FORM,
payload: { formId },
});
export const updateDraft = (name, value) => ({
type: UPDATE_DRAFT,
payload: {
name,
value,
},
});
export const resetDrafts = () => ({
type: RESET_DRAFTS,
});
// SAVE SETTINGS ACTIONS
export const saveSettings = (formId, commitValues) => ({
type: SAVE_SETTINGS.BASE,
payload: { formId, commitValues },
});
export const saveSettingsBegin = () => ({
type: SAVE_SETTINGS.BEGIN,
});
export const saveSettingsSuccess = (values, confirmationValues) => ({
type: SAVE_SETTINGS.SUCCESS,
payload: { values, confirmationValues },
});
export const saveSettingsReset = () => ({
type: SAVE_SETTINGS.RESET,
});
export const saveSettingsFailure = ({ fieldErrors, message }) => ({
type: SAVE_SETTINGS.FAILURE,
payload: { errors: fieldErrors, message },
});
export const savePreviousSiteLanguage = previousSiteLanguage => ({
type: SAVE_PREVIOUS_SITE_LANGUAGE,
payload: { previousSiteLanguage },
});
// FETCH TIME_ZONE ACTIONS
export const fetchTimeZones = country => ({
type: FETCH_TIME_ZONES.BASE,
payload: { country },
});
export const fetchTimeZonesSuccess = timeZones => ({
type: FETCH_TIME_ZONES.SUCCESS,
payload: { timeZones },
});

View File

@@ -0,0 +1,34 @@
export const YEAR_OF_BIRTH_OPTIONS = (() => {
const currentYear = new Date().getFullYear();
const years = [];
let startYear = currentYear - 120;
while (startYear < currentYear) {
startYear += 1;
years.push({ value: startYear, label: startYear });
}
return years.reverse();
})();
export const EDUCATION_LEVELS = [
'',
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'o',
];
export const GENDER_OPTIONS = [
'',
'f',
'm',
'o',
];
export const TRANSIFEX_LANGUAGE_BASE_URL = 'https://www.transifex.com/open-edx/edx-platform/language/';

View File

@@ -0,0 +1,199 @@
import {
FETCH_SETTINGS,
OPEN_FORM,
CLOSE_FORM,
SAVE_SETTINGS,
FETCH_TIME_ZONES,
SAVE_PREVIOUS_SITE_LANGUAGE,
UPDATE_DRAFT,
RESET_DRAFTS,
} from './actions';
import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from '../delete-account';
import { reducer as siteLanguageReducer, FETCH_SITE_LANGUAGES } from '../site-language';
import { reducer as resetPasswordReducer, RESET_PASSWORD } from '../reset-password';
import { reducer as thirdPartyAuthReducer, DISCONNECT_AUTH } from '../third-party-auth';
export const defaultState = {
loading: false,
loaded: false,
loadingError: null,
data: null,
values: {},
errors: {},
confirmationValues: {},
drafts: {},
saveState: null,
timeZones: [],
countryTimeZones: [],
previousSiteLanguage: null,
deleteAccount: deleteAccountReducer(),
siteLanguage: siteLanguageReducer(),
resetPassword: resetPasswordReducer(),
thirdPartyAuth: thirdPartyAuthReducer(),
};
const reducer = (state = defaultState, action) => {
let dispatcherIsOpenForm;
switch (action.type) {
case FETCH_SETTINGS.BEGIN:
return {
...state,
loading: true,
loaded: false,
loadingError: null,
};
case FETCH_SETTINGS.SUCCESS:
return {
...state,
values: Object.assign({}, state.values, action.payload.values),
// Dump the providers into thirdPartyAuth.
thirdPartyAuth: Object.assign({}, state.thirdPartyAuth, {
providers: action.payload.thirdPartyAuthProviders,
}),
profileDataManager: action.payload.profileDataManager,
timeZones: action.payload.timeZones,
loading: false,
loaded: true,
loadingError: null,
};
case FETCH_SETTINGS.FAILURE:
return {
...state,
loading: false,
loaded: false,
loadingError: action.payload.error,
};
case FETCH_SETTINGS.RESET:
return {
...state,
loading: false,
loaded: false,
loadingError: null,
};
case OPEN_FORM:
return {
...state,
openFormId: action.payload.formId,
saveState: null,
errors: {},
drafts: {},
};
case CLOSE_FORM:
dispatcherIsOpenForm = action.payload.formId === state.openFormId;
if (dispatcherIsOpenForm) {
return {
...state,
openFormId: null,
saveState: null,
errors: {},
drafts: {},
};
}
return state;
case UPDATE_DRAFT:
return {
...state,
drafts: Object.assign({}, state.drafts, {
[action.payload.name]: action.payload.value,
}),
saveState: null,
errors: {},
};
case RESET_DRAFTS:
return {
...state,
drafts: {},
};
case SAVE_SETTINGS.BEGIN:
return {
...state,
saveState: 'pending',
errors: {},
};
case SAVE_SETTINGS.SUCCESS:
return {
...state,
saveState: 'complete',
values: Object.assign({}, state.values, action.payload.values),
errors: {},
confirmationValues: Object.assign(
{},
state.confirmationValues,
action.payload.confirmationValues,
),
};
case SAVE_SETTINGS.FAILURE:
return {
...state,
saveState: 'error',
errors: Object.assign({}, state.errors, action.payload.errors),
};
case SAVE_SETTINGS.RESET:
return {
...state,
saveState: null,
errors: {},
};
case SAVE_PREVIOUS_SITE_LANGUAGE:
return {
...state,
previousSiteLanguage: action.payload.previousSiteLanguage,
};
case FETCH_TIME_ZONES.SUCCESS:
return {
...state,
countryTimeZones: action.payload.timeZones,
};
// TODO: Once all the above cases have been converted into sub-reducers, we can use
// combineReducers in this file to greatly simplify it.
// Delete My Account
case DELETE_ACCOUNT.CONFIRMATION:
case DELETE_ACCOUNT.BEGIN:
case DELETE_ACCOUNT.SUCCESS:
case DELETE_ACCOUNT.FAILURE:
case DELETE_ACCOUNT.RESET:
case DELETE_ACCOUNT.CANCEL:
return {
...state,
deleteAccount: deleteAccountReducer(state.deleteAccount, action),
};
case FETCH_SITE_LANGUAGES.BEGIN:
case FETCH_SITE_LANGUAGES.SUCCESS:
case FETCH_SITE_LANGUAGES.FAILURE:
case FETCH_SITE_LANGUAGES.RESET:
return {
...state,
siteLanguage: siteLanguageReducer(state.siteLanguage, action),
};
case RESET_PASSWORD.BEGIN:
case RESET_PASSWORD.SUCCESS:
return {
...state,
resetPassword: resetPasswordReducer(state.resetPassword, action),
};
case DISCONNECT_AUTH.BEGIN:
case DISCONNECT_AUTH.SUCCESS:
case DISCONNECT_AUTH.FAILURE:
case DISCONNECT_AUTH.RESET:
return {
...state,
thirdPartyAuth: thirdPartyAuthReducer(state.thirdPartyAuth, action),
};
default:
return state;
}
};
export default reducer;

View File

@@ -0,0 +1,119 @@
import { call, put, delay, takeEvery, all } from 'redux-saga/effects';
import { publish } from '@edx/frontend-platform';
import { getLocale, handleRtl, LOCALE_CHANGED } from '@edx/frontend-platform/i18n';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
// Actions
import {
FETCH_SETTINGS,
fetchSettingsBegin,
fetchSettingsSuccess,
fetchSettingsFailure,
closeForm,
SAVE_SETTINGS,
saveSettingsBegin,
saveSettingsSuccess,
saveSettingsFailure,
savePreviousSiteLanguage,
FETCH_TIME_ZONES,
fetchTimeZones,
fetchTimeZonesSuccess,
} from './actions';
// Sub-modules
import { saga as deleteAccountSaga } from '../delete-account';
import { saga as resetPasswordSaga } from '../reset-password';
import {
saga as siteLanguageSaga,
patchPreferences,
postSetLang,
} from '../site-language';
import { saga as thirdPartyAuthSaga } from '../third-party-auth';
// Services
import { getSettings, patchSettings, getTimeZones } from './service';
export function* handleFetchSettings() {
try {
yield put(fetchSettingsBegin());
const { username, userId, roles: userRoles } = getAuthenticatedUser();
const {
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
} = yield call(
getSettings,
username,
userRoles,
userId,
);
if (values.country) yield put(fetchTimeZones(values.country));
yield put(fetchSettingsSuccess({
values,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));
throw e;
}
}
export function* handleSaveSettings(action) {
try {
yield put(saveSettingsBegin());
const { username, userId } = getAuthenticatedUser();
const { commitValues, formId } = action.payload;
const commitData = { [formId]: commitValues };
let savedValues = null;
if (formId === 'siteLanguage') {
const previousSiteLanguage = getLocale();
// The following two requests need to be done sequentially, with patching preferences before
// the post to setlang. They used to be done in parallel, but this might create ambiguous
// behavior.
yield call(patchPreferences, username, { prefLang: commitValues });
yield call(postSetLang, commitValues);
yield put(savePreviousSiteLanguage(previousSiteLanguage));
publish(LOCALE_CHANGED, getLocale());
handleRtl();
savedValues = commitData;
} else {
savedValues = yield call(patchSettings, username, commitData, userId);
}
yield put(saveSettingsSuccess(savedValues, commitData));
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
yield delay(1000);
yield put(closeForm(action.payload.formId));
} catch (e) {
if (e.fieldErrors) {
yield put(saveSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveSettingsFailure(e.message));
throw e;
}
}
}
export function* handleFetchTimeZones(action) {
const response = yield call(getTimeZones, action.payload.country);
yield put(fetchTimeZonesSuccess(response, action.payload.country));
}
export default function* saga() {
yield takeEvery(FETCH_SETTINGS.BASE, handleFetchSettings);
yield takeEvery(SAVE_SETTINGS.BASE, handleSaveSettings);
yield takeEvery(FETCH_TIME_ZONES.BASE, handleFetchTimeZones);
yield all([
deleteAccountSaga(),
siteLanguageSaga(),
resetPasswordSaga(),
thirdPartyAuthSaga(),
]);
}

View File

@@ -0,0 +1,161 @@
import { createSelector, createStructuredSelector } from 'reselect';
import { siteLanguageOptionsSelector, siteLanguageListSelector } from '../site-language';
export const storeName = 'accountSettings';
export const accountSettingsSelector = state => ({ ...state[storeName] });
const editableFieldNameSelector = (state, props) => props.name;
const valuesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.values,
);
const draftsSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.drafts,
);
const previousSiteLanguageSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.previousSiteLanguage,
);
const editableFieldErrorSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
(name, accountSettings) => accountSettings.errors[name],
);
const editableFieldConfirmationValuesSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
(name, accountSettings) => accountSettings.confirmationValues[name],
);
const isEditingSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
(name, accountSettings) => accountSettings.openFormId === name,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
);
export const editableFieldSelector = createStructuredSelector({
error: editableFieldErrorSelector,
confirmationValue: editableFieldConfirmationValuesSelector,
saveState: saveStateSelector,
isEditing: isEditingSelector,
});
export const profileDataManagerSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.profileDataManager,
);
export const staticFieldsSelector = createSelector(
accountSettingsSelector,
accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []),
);
export const hiddenFieldsSelector = createSelector(
accountSettingsSelector,
accountSettings => (accountSettings.profileDataManager ? [] : ['secondary_email']),
);
/**
* If there's no draft present at all (undefined), use the original committed value.
*/
function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed;
}
const formValuesSelector = createSelector(
valuesSelector,
draftsSelector,
(values, drafts) => {
const formValues = {};
Object.entries(values).forEach(([name, value]) => {
formValues[name] = chooseFormValue(drafts[name], value) || '';
});
return formValues;
},
);
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
.map(({ time_zone, description }) => ({ // eslint-disable-line camelcase
value: time_zone, label: description,
}));
const timeZonesSelector = createSelector(
accountSettingsSelector,
accountSettings => transformTimeZonesToOptions(accountSettings.timeZones),
);
const countryTimeZonesSelector = createSelector(
accountSettingsSelector,
accountSettings => transformTimeZonesToOptions(accountSettings.countryTimeZones),
);
const activeAccountSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.values.is_active,
);
export const siteLanguageSelector = createSelector(
previousSiteLanguageSelector,
draftsSelector,
(previousValue, drafts) => ({
previousValue,
draft: drafts.siteLanguage,
}),
);
export const betaLanguageBannerSelector = createStructuredSelector({
siteLanguageList: siteLanguageListSelector,
siteLanguage: siteLanguageSelector,
});
export const accountSettingsPageSelector = createSelector(
accountSettingsSelector,
siteLanguageOptionsSelector,
siteLanguageSelector,
formValuesSelector,
profileDataManagerSelector,
staticFieldsSelector,
hiddenFieldsSelector,
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
(
accountSettings,
siteLanguageOptions,
siteLanguage,
formValues,
profileDataManager,
staticFields,
hiddenFields,
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
) => ({
siteLanguageOptions,
siteLanguage,
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
timeZoneOptions,
countryTimeZoneOptions,
isActive: activeAccount,
formValues,
profileDataManager,
staticFields,
hiddenFields,
tpaProviders: accountSettings.thirdPartyAuth.providers,
}),
);

View File

@@ -0,0 +1,207 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import pick from 'lodash.pick';
import omit from 'lodash.omit';
import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
const SOCIAL_PLATFORMS = [
{ id: 'twitter', key: 'social_link_twitter' },
{ id: 'facebook', key: 'social_link_facebook' },
{ id: 'linkedin', key: 'social_link_linkedin' },
];
function unpackAccountResponseData(data) {
const unpackedData = data;
// This is handled by preferences
delete unpackedData.time_zone;
SOCIAL_PLATFORMS.forEach(({ id, key }) => {
const platformData = data.social_links.find(({ platform }) => platform === id);
unpackedData[key] = typeof platformData === 'object' ? platformData.social_link : '';
});
if (Array.isArray(data.language_proficiencies)) {
if (data.language_proficiencies.length) {
unpackedData.language_proficiencies = data.language_proficiencies[0].code;
} else {
unpackedData.language_proficiencies = '';
}
}
return unpackedData;
}
function packAccountCommitData(commitData) {
const packedData = commitData;
SOCIAL_PLATFORMS.forEach(({ id, key }) => {
// Skip missing values. Empty strings are valid values and should be preserved.
if (commitData[key] === undefined) return;
packedData.social_links = [{ platform: id, social_link: commitData[key] }];
delete packedData[key];
});
if (commitData.language_proficiencies !== undefined) {
if (commitData.language_proficiencies) {
packedData.language_proficiencies = [{ code: commitData.language_proficiencies }];
} else {
// An empty string should be sent as an array.
packedData.language_proficiencies = [];
}
}
if (commitData.year_of_birth !== undefined) {
if (commitData.year_of_birth) {
packedData.year_of_birth = commitData.year_of_birth;
} else {
// An empty string should be sent as null.
packedData.year_of_birth = null;
}
}
return packedData;
}
export async function getAccount(username) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
return unpackAccountResponseData(data);
}
export async function patchAccount(username, commitValues) {
const requestConfig = {
headers: { 'Content-Type': 'application/merge-patch+json' },
};
const { data } = await getAuthenticatedHttpClient()
.patch(
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`,
packAccountCommitData(commitValues),
requestConfig,
)
.catch((error) => {
const unpackFunction = (fieldErrors) => {
const unpackedFieldErrors = fieldErrors;
if (fieldErrors.social_links) {
SOCIAL_PLATFORMS.forEach(({ key }) => {
unpackedFieldErrors[key] = fieldErrors.social_links;
});
}
return unpackFieldErrors(unpackedFieldErrors);
};
handleRequestError(error, unpackFunction);
});
return unpackAccountResponseData(data);
}
export async function getPreferences(username) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
return data;
}
export async function patchPreferences(username, commitValues) {
const requestConfig = { headers: { 'Content-Type': 'application/merge-patch+json' } };
const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`;
// Ignore the success response, the API does not currently return any data.
await getAuthenticatedHttpClient()
.patch(requestUrl, commitValues, requestConfig).catch(handleRequestError);
return commitValues;
}
export async function getTimeZones(forCountry) {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/user_api/v1/preferences/time_zones/`, {
params: { country_code: forCountry },
})
.catch(handleRequestError);
return data;
}
/**
* Determine if the user's profile data is managed by a third-party identity provider.
*/
export async function getProfileDataManager(username, userRoles) {
const userRoleNames = userRoles.map(role => role.split(':')[0]);
if (userRoleNames.includes('enterprise_learner')) {
const url = `${getConfig().LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/?username=${username}`;
const { data } = await getAuthenticatedHttpClient().get(url).catch(handleRequestError);
if ('results' in data) {
for (let i = 0; i < data.results.length; i += 1) {
const enterprise = data.results[i].enterprise_customer;
if (enterprise.sync_learner_profile_data) {
return enterprise.name;
}
}
}
}
return null;
}
/**
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, Coaching, and ThirdPartyAuth
*/
export async function getSettings(username, userRoles, userId) {
const results = await Promise.all([
getAccount(username),
getPreferences(username),
getThirdPartyAuthProviders(),
getProfileDataManager(username, userRoles),
getTimeZones(),
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
]);
return {
...results[0],
...results[1],
thirdPartyAuthProviders: results[2],
profileDataManager: results[3],
timeZones: results[4],
coaching: results[5],
};
}
/**
* A single function to PATCH everything considered a setting.
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
*/
export async function patchSettings(username, commitValues, userId) {
// Note: time_zone exists in the return value from user/v1/accounts
// but it is always null and won't update. It also exists in
// user/v1/preferences where it does update. This is the one we use.
const preferenceKeys = ['time_zone'];
const coachingKeys = ['coaching'];
const accountCommitValues = omit(commitValues, preferenceKeys);
const preferenceCommitValues = pick(commitValues, preferenceKeys);
const coachingCommitValues = pick(commitValues, coachingKeys);
const patchRequests = [];
if (!isEmpty(accountCommitValues)) {
patchRequests.push(patchAccount(username, accountCommitValues));
}
if (!isEmpty(preferenceCommitValues)) {
patchRequests.push(patchPreferences(username, preferenceCommitValues));
}
if (!isEmpty(coachingCommitValues)) {
patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues));
}
const results = await Promise.all(patchRequests);
// Assigns in order of requests. Preference keys
// will override account keys. Notably time_zone.
const combinedResults = Object.assign({}, ...results);
return combinedResults;
}

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getModuleState should throw an exception on a bad path 1`] = `"Unexpected state key uhoh given to getModuleState. Is your state path set up correctly?"`;

View File

@@ -36,46 +36,3 @@ export function convertKeyNames(object, nameMap) {
return modifyObjectKeys(object, transformer);
}
export function keepKeys(data, whitelist) {
const result = {};
Object.keys(data).forEach((key) => {
if (whitelist.indexOf(key) > -1) {
result[key] = data[key];
}
});
return result;
}
/**
* 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,4 +1,9 @@
import { AsyncActionType, modifyObjectKeys, camelCaseObject, snakeCaseObject, convertKeyNames, keepKeys } from './utils';
import {
modifyObjectKeys,
camelCaseObject,
snakeCaseObject,
convertKeyNames,
} from './dataUtils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
@@ -83,36 +88,3 @@ describe('convertKeyNames', () => {
});
});
});
describe('keepKeys', () => {
it('should keep the specified keys only', () => {
const result = keepKeys({
one: 123,
two: { three: 'skip me' },
four: 'five',
six: null,
8: 'sneaky',
}, [
'one', 'three', 'six', 'seven', '8', // yup, the 8 integer will be converted to a string.
]);
expect(result).toEqual({
one: 123,
six: null,
8: 'sneaky',
});
});
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

@@ -0,0 +1,12 @@
export {
camelCaseObject,
convertKeyNames,
modifyObjectKeys,
snakeCaseObject,
} from './dataUtils';
export {
AsyncActionType,
getModuleState,
} from './reduxUtils';
export { default as handleFailure } from './sagaUtils';
export { unpackFieldErrors, handleRequestError } from './serviceUtils';

View File

@@ -0,0 +1,62 @@
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*/
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`;
}
}
/**
* Given a state tree and an array representing a set of keys to traverse in that tree, returns
* the portion of the tree at that key path.
*
* Example:
*
* const result = getModuleState(
* {
* first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
* second: { other: 'data', }
* },
* ['first', 'red']
* );
*
* result will be:
*
* {
* awesome: 'sauce'
* }
*/
export function getModuleState(state, originalPath) {
const path = [...originalPath]; // don't modify your argument
if (path.length < 1) {
return state;
}
const key = path.shift();
if (state[key] === undefined) {
throw new Error(`Unexpected state key ${key} given to getModuleState. Is your state path set up correctly?`);
}
return getModuleState(state[key], path);
}

View File

@@ -0,0 +1,51 @@
import {
AsyncActionType,
getModuleState,
} from './reduxUtils';
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');
});
});
describe('getModuleState', () => {
const state = {
first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
second: { other: 'data' },
};
it('should return everything if given an empty path', () => {
expect(getModuleState(state, [])).toEqual(state);
});
it('should resolve paths correctly', () => {
expect(getModuleState(
state,
['first'],
)).toEqual({ red: { awesome: 'sauce' }, blue: { weak: 'sauce' } });
expect(getModuleState(
state,
['first', 'red'],
)).toEqual({ awesome: 'sauce' });
expect(getModuleState(state, ['second'])).toEqual({ other: 'data' });
});
it('should throw an exception on a bad path', () => {
expect(() => {
getModuleState(state, ['uhoh']);
}).toThrowErrorMatchingSnapshot();
});
it('should return non-objects correctly', () => {
expect(getModuleState(state, ['first', 'red', 'awesome'])).toEqual('sauce');
});
});

View File

@@ -0,0 +1,16 @@
import { put } from 'redux-saga/effects';
import { logError } from '@edx/frontend-platform/logging';
import { history } from '@edx/frontend-platform';
export default function* handleFailure(error, failureAction = null, failureRedirectPath = null) {
if (error.fieldErrors && failureAction !== null) {
yield put(failureAction({ fieldErrors: error.fieldErrors }));
}
logError(error);
if (failureAction !== null) {
yield put(failureAction(error.message));
}
if (failureRedirectPath !== null) {
history.push(failureRedirectPath);
}
}

View File

@@ -0,0 +1,48 @@
/**
* Turns field errors of the form:
*
* {
* "name":{
* "developer_message": "Nerdy message here",
* "user_message": "This value is invalid."
* },
* "other_field": {
* "developer_message": "Other Nerdy message here",
* "user_message": "This other value is invalid."
* }
* }
*
* Into:
*
* {
* "name": "This value is invalid.",
* "other_field": "This other value is invalid"
* }
*/
export function unpackFieldErrors(fieldErrors) {
return Object.entries(fieldErrors).reduce((acc, [k, v]) => {
acc[k] = v.user_message;
return acc;
}, {});
}
/**
* Processes and re-throws request errors. If the response contains a field_errors field, will
* massage the data into a form expected by the client.
*
* Field errors will be packaged as an api error with a fieldErrors field usable by the client.
* Takes an optional unpack function which is used to process the field errors,
* otherwise uses the default unpackFieldErrors function.
*
* @param error The original error object.
* @param unpackFunction (Optional) A function to use to unpack the field errors as a replacement
* for the default.
*/
export function handleRequestError(error, unpackFunction = unpackFieldErrors) {
if (error.response && error.response.data.field_errors) {
const apiError = Object.create(error);
apiError.fieldErrors = unpackFunction(error.response.data.field_errors);
throw apiError;
}
throw error;
}

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Hyperlink } from '@edx/paragon';
// Messages
import messages from './messages';
// Components
import Alert from '../Alert';
const BeforeProceedingBanner = (props) => {
const { instructionMessageId, intl, supportArticleUrl } = props;
return (
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<FormattedMessage
id="account.settings.delete.account.before.proceeding"
defaultMessage="Before proceeding, please {actionLink}."
description="Error that appears if you are trying to delete your edX account, but something about your account needs attention first. The actionLink will be instructions, such as 'unlink your Facebook account'."
values={{
actionLink: (
<Hyperlink destination={supportArticleUrl}>
{intl.formatMessage(messages[instructionMessageId])}
</Hyperlink>
),
}}
/>
</Alert>
);
};
BeforeProceedingBanner.propTypes = {
instructionMessageId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
supportArticleUrl: PropTypes.string.isRequired,
};
export default injectIntl(BeforeProceedingBanner);

View File

@@ -0,0 +1,130 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Button, Input, Modal, ValidationFormGroup } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import messages from './messages';
import Alert from '../Alert';
import PrintingInstructions from './PrintingInstructions';
export class ConfirmationModal extends Component {
/**
* @returns String The message id for a short description of the error, suitable for a header or
* as the error message under an input field.
*/
getShortErrorMessageId(reason) {
switch (reason) {
case 'empty-password':
return 'account.settings.delete.account.error.no.password';
default:
return 'account.settings.delete.account.error.unable.to.delete';
}
}
renderError(reason) {
const { errorType, intl } = this.props;
if (errorType === null) {
return null;
}
const headerMessageId = this.getShortErrorMessageId(errorType);
const detailsMessageId =
reason === 'empty-password'
? null
: 'account.settings.delete.account.error.unable.to.delete.details';
return (
<Alert
className="alert-danger mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationCircle} />}
>
<h6>{intl.formatMessage(messages[headerMessageId])}</h6>
{detailsMessageId ? (
<p className="text-danger">{intl.formatMessage(messages[detailsMessageId])}</p>
) : null}
</Alert>
);
}
render() {
const {
status,
errorType,
intl,
onCancel,
onChange,
onSubmit,
password,
} = this.props;
const open = ['confirming', 'pending', 'failed'].includes(status);
const passwordFieldId = 'passwordFieldId';
const invalidMessage = messages[this.getShortErrorMessageId(errorType)];
return (
<Modal
open={open}
title={intl.formatMessage(messages['account.settings.delete.account.modal.header'])}
body={
<div>
{this.renderError()}
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<h6>
{intl.formatMessage(messages['account.settings.delete.account.modal.text.1'])}
</h6>
<p>{intl.formatMessage(messages['account.settings.delete.account.modal.text.2'])}</p>
<p>
<PrintingInstructions />
</p>
</Alert>
<ValidationFormGroup
for={passwordFieldId}
invalid={errorType !== null}
invalidMessage={intl.formatMessage(invalidMessage)}
>
<label className="d-block" htmlFor={passwordFieldId}>
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
</label>
<Input
name="password"
id={passwordFieldId}
type="password"
value={password}
onChange={onChange}
/>
</ValidationFormGroup>
</div>
}
buttons={[
<Button className="btn-danger" onClick={onSubmit}>
{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.delete'])}
</Button>,
]}
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.confirm.cancel'])}
renderHeaderCloseButton={false}
onClose={onCancel}
/>
);
}
}
ConfirmationModal.propTypes = {
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
errorType: PropTypes.oneOf(['empty-password', 'server']),
intl: intlShape.isRequired,
onCancel: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
password: PropTypes.string.isRequired,
};
ConfirmationModal.defaultProps = {
status: null,
errorType: null,
};
export default injectIntl(ConfirmationModal);

View File

@@ -0,0 +1,68 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
import { ConfirmationModal } from './ConfirmationModal'; // eslint-disable-line import/first
const IntlConfirmationModal = injectIntl(ConfirmationModal);
describe('ConfirmationModal', () => {
let props = {};
beforeEach(() => {
props = {
onCancel: jest.fn(),
onChange: jest.fn(),
onSubmit: jest.fn(),
status: null,
errorType: null,
password: 'fluffy bunnies',
};
});
it('should match default closed confirmation modal snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlConfirmationModal
{...props}
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match open confirmation modal snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlConfirmationModal
{...props}
status="pending" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match empty password confirmation modal snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlConfirmationModal
{...props}
errorType="empty-password"
status="pending" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,149 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@edx/paragon';
// Actions
import {
deleteAccount,
deleteAccountConfirmation,
deleteAccountFailure,
deleteAccountReset,
deleteAccountCancel,
} from './data/actions';
// Messages
import messages from './messages';
// Components
import ConnectedConfirmationModal from './ConfirmationModal';
import PrintingInstructions from './PrintingInstructions';
import ConnectedSuccessModal from './SuccessModal';
import BeforeProceedingBanner from './BeforeProceedingBanner';
export class DeleteAccount extends React.Component {
state = {
password: '',
};
handleSubmit = () => {
if (this.state.password === '') {
this.props.deleteAccountFailure('empty-password');
} else {
this.props.deleteAccount(this.state.password);
}
};
handleCancel = () => {
this.setState({ password: '' });
this.props.deleteAccountCancel();
};
handlePasswordChange = (e) => {
this.setState({ password: e.target.value.trim() });
this.props.deleteAccountReset();
};
handleFinalClose = () => {
global.location = getConfig().LOGOUT_URL;
};
render() {
const {
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
} = this.props;
const canDelete = isVerifiedAccount && !hasLinkedTPA;
return (
<div>
<h2 className="section-heading">
{intl.formatMessage(messages['account.settings.delete.account.header'])}
</h2>
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
<p>{intl.formatMessage(messages['account.settings.delete.account.text.1'])}</p>
<p>{intl.formatMessage(messages['account.settings.delete.account.text.2'])}</p>
<p>
<PrintingInstructions />
</p>
<p className="text-danger h6">
{intl.formatMessage(messages['account.settings.delete.account.text.warning'])}
</p>
<p>
<Hyperlink destination="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings">
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
</Hyperlink>
</p>
<p>
<Button
className="btn-outline-danger"
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
disabled={!canDelete}
>
{intl.formatMessage(messages['account.settings.delete.account.button'])}
</Button>
</p>
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.activate"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
/>
)}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/207206067"
/>
) : null}
<ConnectedConfirmationModal
status={status}
errorType={errorType}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
onChange={this.handlePasswordChange}
password={this.state.password}
/>
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
</div>
);
}
}
DeleteAccount.propTypes = {
deleteAccount: PropTypes.func.isRequired,
deleteAccountConfirmation: PropTypes.func.isRequired,
deleteAccountFailure: PropTypes.func.isRequired,
deleteAccountReset: PropTypes.func.isRequired,
deleteAccountCancel: PropTypes.func.isRequired,
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
errorType: PropTypes.oneOf(['empty-password', 'server']),
hasLinkedTPA: PropTypes.bool,
isVerifiedAccount: PropTypes.bool,
intl: intlShape.isRequired,
};
DeleteAccount.defaultProps = {
hasLinkedTPA: false,
isVerifiedAccount: true,
status: null,
errorType: null,
};
// Assume we're part of the accountSettings state.
const mapStateToProps = state => state.accountSettings.deleteAccount;
export default connect(
mapStateToProps,
{
deleteAccount,
deleteAccountConfirmation,
deleteAccountFailure,
deleteAccountReset,
deleteAccountCancel,
},
)(injectIntl(DeleteAccount));

View File

@@ -0,0 +1,70 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Testing the modals separately, they just clutter up the snapshots if included here.
jest.mock('./ConfirmationModal');
jest.mock('./SuccessModal');
import { DeleteAccount } from './DeleteAccount'; // eslint-disable-line import/first
const IntlDeleteAccount = injectIntl(DeleteAccount);
describe('DeleteAccount', () => {
let props = {};
beforeEach(() => {
props = {
deleteAccount: jest.fn(),
deleteAccountConfirmation: jest.fn(),
deleteAccountFailure: jest.fn(),
deleteAccountReset: jest.fn(),
deleteAccountCancel: jest.fn(),
status: null,
errorType: null,
hasLinkedTPA: false,
isVerifiedAccount: true,
};
});
it('should match default section snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlDeleteAccount
{...props}
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match unverified account section snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlDeleteAccount
{...props}
isVerifiedAccount={false}
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match unverified account section snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlDeleteAccount
{...props}
hasLinkedTPA
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import messages from './messages';
const PrintingInstructions = (props) => {
const actionLink = (
<Hyperlink
destination="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
>
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
</Hyperlink>
);
return (
<FormattedMessage
id="account.settings.delete.account.text.3"
defaultMessage="You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion, {actionLink}."
description="A message in the user account deletion area"
values={{ actionLink }}
/>
);
};
PrintingInstructions.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(PrintingInstructions);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Modal } from '@edx/paragon';
import messages from './messages';
export const SuccessModal = (props) => {
const { status, intl, onClose } = props;
return (
<Modal
open={status === 'deleted'}
title={intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])}
body={
<div>
<p className="h6">
{intl.formatMessage(messages['account.settings.delete.account.modal.after.text'])}
</p>
</div>
}
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}
renderHeaderCloseButton={false}
onClose={onClose}
/>
);
};
SuccessModal.propTypes = {
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
SuccessModal.defaultProps = {
status: null,
};
export default injectIntl(SuccessModal);

View File

@@ -0,0 +1,58 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
import { SuccessModal } from './SuccessModal'; // eslint-disable-line import/first
const IntlSuccessModal = injectIntl(SuccessModal);
describe('SuccessModal', () => {
let props = {};
beforeEach(() => {
props = {
onClose: jest.fn(),
status: null,
};
});
it('should match default closed success modal snapshot', () => {
let tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>))
.toJSON();
expect(tree).toMatchSnapshot();
tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} status="confirming" /></IntlProvider>))
.toJSON();
expect(tree).toMatchSnapshot();
tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} status="pending" /></IntlProvider>))
.toJSON();
expect(tree).toMatchSnapshot();
tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match open success modal snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlSuccessModal
{...props}
status="deleted" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,439 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `
<div>
<div
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click fade"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id2"
aria-modal={true}
className=""
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id2"
>
Are you sure?
</h2>
</div>
<div
className="modal-body"
>
<div>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
<h6>
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
</h6>
<p>
If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
</p>
<p>
<span>
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
<a
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
onClick={[Function]}
target="_self"
>
follow the instructions for printing or downloading a certificate
</a>
.
</span>
</p>
</div>
</div>
<div
className="form-group"
>
<label
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<input
aria-describedby=""
className="form-control"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
>
Unable to delete account
</strong>
</div>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-danger"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Yes, Delete
</button>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton1"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`ConfirmationModal should match empty password confirmation modal snapshot 1`] = `
<div>
<div
className="modal-backdrop show"
role="presentation"
/>
<div
className="modal js-close-modal-on-click show d-block"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id6"
aria-modal={true}
className="modal-dialog"
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id6"
>
Are you sure?
</h2>
</div>
<div
className="modal-body"
>
<div>
<div
className="alert d-flex align-items-start alert-danger mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-exclamation-circle fa-w-16 mr-2"
data-icon="exclamation-circle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
<h6>
A password is required
</h6>
<p
className="text-danger"
>
Sorry, there was an error trying to process your request. Please try again later.
</p>
</div>
</div>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
<h6>
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
</h6>
<p>
If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
</p>
<p>
<span>
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
<a
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
onClick={[Function]}
target="_self"
>
follow the instructions for printing or downloading a certificate
</a>
.
</span>
</p>
</div>
</div>
<div
className="form-group"
>
<label
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<input
aria-describedby="passwordFieldId-invalid-feedback"
className="form-control is-invalid"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
>
A password is required
</strong>
</div>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-danger"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Yes, Delete
</button>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton5"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
<div>
<div
className="modal-backdrop show"
role="presentation"
/>
<div
className="modal js-close-modal-on-click show d-block"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id4"
aria-modal={true}
className="modal-dialog"
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id4"
>
Are you sure?
</h2>
</div>
<div
className="modal-body"
>
<div>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
<h6>
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
</h6>
<p>
If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer's or university's system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
</p>
<p>
<span>
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
<a
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
onClick={[Function]}
target="_self"
>
follow the instructions for printing or downloading a certificate
</a>
.
</span>
</p>
</div>
</div>
<div
className="form-group"
>
<label
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<input
aria-describedby=""
className="form-control"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
>
Unable to delete account
</strong>
</div>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-danger"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Yes, Delete
</button>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton3"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,247 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DeleteAccount should match default section snapshot 1`] = `
<div>
<h2
className="section-heading"
>
Delete My Account
</h2>
<p>
We're sorry to see you go!
</p>
<p>
Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
</p>
<p>
Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
</p>
<p>
<span>
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
<a
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
onClick={[Function]}
target="_self"
>
follow the instructions for printing or downloading a certificate
</a>
.
</span>
</p>
<p
className="text-danger h6"
>
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.
</p>
<p>
<a
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
</a>
</p>
<p>
<button
className="btn btn-outline-danger"
disabled={false}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Delete My Account
</button>
</p>
</div>
`;
exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<div>
<h2
className="section-heading"
>
Delete My Account
</h2>
<p>
We're sorry to see you go!
</p>
<p>
Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
</p>
<p>
Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
</p>
<p>
<span>
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
<a
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
onClick={[Function]}
target="_self"
>
follow the instructions for printing or downloading a certificate
</a>
.
</span>
</p>
<p
className="text-danger h6"
>
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.
</p>
<p>
<a
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
</a>
</p>
<p>
<button
className="btn btn-outline-danger"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Delete My Account
</button>
</p>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
<span>
Before proceeding, please
<a
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
onClick={[Function]}
target="_self"
>
activate your account
</a>
.
</span>
</div>
</div>
</div>
`;
exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<div>
<h2
className="section-heading"
>
Delete My Account
</h2>
<p>
We're sorry to see you go!
</p>
<p>
Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.
</p>
<p>
Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.
</p>
<p>
<span>
You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion,
<a
href="https://edx.readthedocs.io/projects/edx-guide-for-students/en/latest/SFD_certificates.html#printing-a-certificate"
onClick={[Function]}
target="_self"
>
follow the instructions for printing or downloading a certificate
</a>
.
</span>
</p>
<p
className="text-danger h6"
>
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.
</p>
<p>
<a
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
</a>
</p>
<p>
<button
className="btn btn-outline-danger"
disabled={true}
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Delete My Account
</button>
</p>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
<span>
Before proceeding, please
<a
href="https://support.edx.org/hc/en-us/articles/207206067"
onClick={[Function]}
target="_self"
>
unlink all social media accounts
</a>
.
</span>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,311 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SuccessModal should match default closed success modal snapshot 1`] = `
<div>
<div
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click fade"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id2"
aria-modal={true}
className=""
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id2"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton1"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`SuccessModal should match default closed success modal snapshot 2`] = `
<div>
<div
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click fade"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id4"
aria-modal={true}
className=""
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id4"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton3"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`SuccessModal should match default closed success modal snapshot 3`] = `
<div>
<div
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click fade"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id6"
aria-modal={true}
className=""
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id6"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton5"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`SuccessModal should match default closed success modal snapshot 4`] = `
<div>
<div
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click fade"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id8"
aria-modal={true}
className=""
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id8"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton7"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`SuccessModal should match open success modal snapshot 1`] = `
<div>
<div
className="modal-backdrop show"
role="presentation"
/>
<div
className="modal js-close-modal-on-click show d-block"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id10"
aria-modal={true}
className="modal-dialog"
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id10"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton9"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
`;

View File

@@ -0,0 +1,37 @@
import { AsyncActionType } from '../../data/utils';
export const DELETE_ACCOUNT = new AsyncActionType('ACCOUNT_SETTINGS', 'DELETE_ACCOUNT');
DELETE_ACCOUNT.CONFIRMATION = 'ACCOUNT_SETTINGS__DELETE_ACCOUNT__CONFIRMATION';
DELETE_ACCOUNT.CANCEL = 'ACCOUNT_SETTINGS__DELETE_ACCOUNT__CANCEL';
export const deleteAccount = password => ({
type: DELETE_ACCOUNT.BASE,
payload: { password },
});
export const deleteAccountConfirmation = () => ({
type: DELETE_ACCOUNT.CONFIRMATION,
});
export const deleteAccountBegin = () => ({
type: DELETE_ACCOUNT.BEGIN,
});
export const deleteAccountSuccess = () => ({
type: DELETE_ACCOUNT.SUCCESS,
});
export const deleteAccountFailure = reason => ({
type: DELETE_ACCOUNT.FAILURE,
payload: { reason },
});
// to clear errors from the confirmation modal
export const deleteAccountReset = () => ({
type: DELETE_ACCOUNT.RESET,
});
// to close the modal
export const deleteAccountCancel = () => ({
type: DELETE_ACCOUNT.CANCEL,
});

View File

@@ -0,0 +1,60 @@
import { DELETE_ACCOUNT } from './actions';
export const defaultState = {
status: null,
errorType: null,
};
const reducer = (state = defaultState, action = null) => {
if (action !== null) {
switch (action.type) {
case DELETE_ACCOUNT.CONFIRMATION:
return {
...state,
status: 'confirming',
};
case DELETE_ACCOUNT.BEGIN:
return {
...state,
status: 'pending',
};
case DELETE_ACCOUNT.SUCCESS:
return {
...state,
status: 'deleted',
};
case DELETE_ACCOUNT.FAILURE:
return {
...state,
status: 'failed',
errorType: action.payload.reason || 'server',
};
case DELETE_ACCOUNT.RESET: {
const oldStatus = state.status;
return {
...state,
// clear the error state if applicable, otherwise don't change state
status: oldStatus === 'failed' ? 'confirming' : oldStatus,
errorType: null,
};
}
case DELETE_ACCOUNT.CANCEL:
return {
...state,
status: null,
errorType: null,
};
default:
}
}
return state;
};
export default reducer;

View File

@@ -0,0 +1,107 @@
import reducer from './reducers';
import {
deleteAccountConfirmation,
deleteAccountBegin,
deleteAccountSuccess,
deleteAccountFailure,
deleteAccountReset,
deleteAccountCancel,
} from './actions';
describe('delete-account reducer', () => {
let state = null;
beforeEach(() => {
state = reducer();
});
it('should process DELETE_ACCOUNT.CONFIRMATION', () => {
const result = reducer(state, deleteAccountConfirmation());
expect(result).toEqual({
errorType: null,
status: 'confirming',
});
});
it('should process DELETE_ACCOUNT.BEGIN', () => {
const result = reducer(state, deleteAccountBegin());
expect(result).toEqual({
errorType: null,
status: 'pending',
});
});
it('should process DELETE_ACCOUNT.SUCCESS', () => {
const result = reducer(state, deleteAccountSuccess());
expect(result).toEqual({
errorType: null,
status: 'deleted',
});
});
it('should process DELETE_ACCOUNT.FAILURE no reason', () => {
const result = reducer(state, deleteAccountFailure());
expect(result).toEqual({
errorType: 'server',
status: 'failed',
});
});
it('should process DELETE_ACCOUNT.FAILURE with reason', () => {
const result = reducer(state, deleteAccountFailure('carnivorous buns'));
expect(result).toEqual({
errorType: 'carnivorous buns',
status: 'failed',
});
});
it('should process DELETE_ACCOUNT.RESET no status', () => {
const result = reducer(state, deleteAccountReset());
expect(result).toEqual({
errorType: null,
status: null,
});
});
it('should process DELETE_ACCOUNT.RESET with failed old status', () => {
const result = reducer(
{
errorType: 'carnivorous buns',
status: 'failed',
},
deleteAccountReset(),
);
expect(result).toEqual({
errorType: null,
status: 'confirming',
});
});
it('should process DELETE_ACCOUNT.RESET with pending old status', () => {
const result = reducer(
{
errorType: 'carnivorous buns',
status: 'pending',
},
deleteAccountReset(),
);
expect(result).toEqual({
errorType: null,
status: 'pending',
});
});
it('should process DELETE_ACCOUNT.CANCEL', () => {
const result = reducer(
{
errorType: 'carnivorous buns',
status: 'failed',
},
deleteAccountCancel(),
);
expect(result).toEqual({
errorType: null,
status: null,
});
});
});

View File

@@ -0,0 +1,28 @@
import { put, call, takeEvery } from 'redux-saga/effects';
import {
DELETE_ACCOUNT,
deleteAccountBegin,
deleteAccountSuccess,
deleteAccountFailure,
} from './actions';
import { postDeleteAccount } from './service';
export function* handleDeleteAccount(action) {
try {
yield put(deleteAccountBegin());
const response = yield call(postDeleteAccount, action.payload.password);
yield put(deleteAccountSuccess(response));
} catch (e) {
if (typeof e.response.data === 'string') {
yield put(deleteAccountFailure());
} else {
throw e;
}
}
}
export default function* saga() {
yield takeEvery(DELETE_ACCOUNT.BASE, handleDeleteAccount);
}

View File

@@ -0,0 +1,23 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { handleRequestError } from '../../data/utils';
/**
* Request deletion of the user's account.
*/
// eslint-disable-next-line import/prefer-default-export
export async function postDeleteAccount(password) {
const { data } = await getAuthenticatedHttpClient()
.post(
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/deactivate_logout/`,
formurlencoded({ password }),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
)
.catch(handleRequestError);
return data;
}

View File

@@ -0,0 +1,4 @@
export { default } from './DeleteAccount';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { DELETE_ACCOUNT } from './data/actions';

View File

@@ -0,0 +1,116 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.delete.account.header': {
id: 'account.settings.delete.account.header',
defaultMessage: 'Delete My Account',
description: 'Header for the user account deletion area',
},
'account.settings.delete.account.subheader': {
id: 'account.settings.delete.account.subheader',
defaultMessage: 'We\'re sorry to see you go!',
description: 'A message in the user account deletion area',
},
'account.settings.delete.account.text.1': {
id: 'account.settings.delete.account.text.1',
defaultMessage: 'Please note: Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.',
description: 'A message in the user account deletion area',
},
'account.settings.delete.account.text.2': {
id: 'account.settings.delete.account.text.2',
defaultMessage: 'Once your account is deleted, you cannot use it to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employers or universitys system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.',
description: 'A message in the user account deletion area',
},
'account.settings.delete.account.text.3.link': {
id: 'account.settings.delete.account.text.3.link',
defaultMessage: 'follow the instructions for printing or downloading a certificate',
description: 'This text will be a link to a technical support page; it will go in the phrase If you want to make a copy of these for your records, ______ .',
},
'account.settings.delete.account.text.warning': {
id: 'account.settings.delete.account.text.warning',
defaultMessage: 'Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on edX.',
description: 'A message in the user account deletion area',
},
'account.settings.delete.account.text.change.instead': {
id: 'account.settings.delete.account.text.change.instead',
defaultMessage: 'Want to change your email, name, or password instead?',
description: 'A message in the user account deletion area',
},
'account.settings.delete.account.button': {
id: 'account.settings.delete.account.button',
defaultMessage: 'Delete My Account',
description: 'Button label to permanently delete your edX account',
},
'account.settings.delete.account.please.activate': {
id: 'account.settings.delete.account.please.activate',
defaultMessage: 'activate your account',
description: 'This is the text on a link that goes to the support page. It is part of this sentence: Before proceeding, please activate your account.',
},
'account.settings.delete.account.please.unlink': {
id: 'account.settings.delete.account.please.unlink',
defaultMessage: 'unlink all social media accounts',
description: 'This is the text on a link that goes to the support page. It is part of this sentence: Before proceeding, please unlink all social media accounts.',
},
'account.settings.delete.account.modal.header': {
id: 'account.settings.delete.account.modal.header',
defaultMessage: 'Are you sure?',
description: 'Title of the dialog asking user to confirm that they want to delete their entire account',
},
'account.settings.delete.account.modal.text.1': {
id: 'account.settings.delete.account.modal.text.1',
defaultMessage: 'You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. edX will not be able to recover your account or the data that is deleted.',
description: 'Messaging in the dialog asking user to confirm that they want to delete their entire account',
},
'account.settings.delete.account.modal.text.2': {
id: 'account.settings.delete.account.modal.text.2',
defaultMessage: 'If you proceed, you will be unable to use this account to take courses on the edX app, edx.org, or any other site hosted by edX. This includes access to edx.org from your employer\'s or university\'s system and access to private sites offered by MIT Open Learning, Wharton Executive Education, and Harvard Medical School.',
description: 'Messaging in the dialog asking user to confirm that they want to delete their entire account',
},
'account.settings.delete.account.modal.enter.password': {
id: 'account.settings.delete.account.modal.enter.password',
defaultMessage: 'If you still wish to continue and delete your account, please enter your account password:',
description: 'Asking for the user\'s account password',
},
'account.settings.delete.account.modal.confirm.delete': {
id: 'account.settings.delete.account.modal.confirm.delete',
defaultMessage: 'Yes, Delete',
description: 'Button label for user to confirm it is okay to delete their account',
},
'account.settings.delete.account.modal.confirm.cancel': {
id: 'account.settings.delete.account.modal.confirm.cancel',
defaultMessage: 'Cancel',
description: 'The cancel button on the delete my account modal confirmation',
},
'account.settings.delete.account.error.unable.to.delete': {
id: 'account.settings.delete.account.error.unable.to.delete',
defaultMessage: 'Unable to delete account',
description: 'Error message when account deletion failed',
},
'account.settings.delete.account.error.no.password': {
id: 'account.settings.delete.account.error.no.password',
defaultMessage: 'A password is required',
description: 'Error message when user has not entered their password',
},
'account.settings.delete.account.error.unable.to.delete.details': {
id: 'account.settings.delete.account.error.unable.to.delete.details',
defaultMessage: 'Sorry, there was an error trying to process your request. Please try again later.',
description: 'Error message when account deletion failed',
},
'account.settings.delete.account.modal.after.header': {
id: 'account.settings.delete.account.modal.after.header',
defaultMessage: 'We\'re sorry to see you go! Your account will be deleted shortly.',
description: 'Title displayed after user account is deleted',
},
'account.settings.delete.account.modal.after.text': {
id: 'account.settings.delete.account.modal.after.text',
defaultMessage: 'Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.',
description: 'Text displayed after user account is deleted',
},
'account.settings.delete.account.modal.after.button': {
id: 'account.settings.delete.account.modal.after.button',
defaultMessage: 'Close',
description: 'Label on button to close a dialog',
},
});
export default messages;

View File

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

View File

@@ -0,0 +1,47 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import Alert from '../Alert';
const ConfirmationAlert = (props) => {
const { email } = props;
const technicalSupportLink = (
<Hyperlink
destination="https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-"
>
<FormattedMessage
id="account.settings.editable.field.password.reset.button.confirmation.support.link"
defaultMessage="technical support"
description="link text used in message: account.settings.editable.field.password.reset.button.confirmation 'Contact technical support.'"
/>
</Hyperlink>
);
return (
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<FormattedMessage
id="account.settings.editable.field.password.reset.button.confirmation"
defaultMessage="We've sent a message to {email}. Click the link in the message to reset your password. Didn't receive the message? Contact {technicalSupportLink}."
description="The password reset button in account settings"
values={{
email,
technicalSupportLink,
}}
/>
</Alert>
);
};
ConfirmationAlert.propTypes = {
email: PropTypes.string.isRequired,
};
export default ConfirmationAlert;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { StatefulButton } from '@edx/paragon';
import { resetPassword } from './data/actions';
import messages from './messages';
import ConfirmationAlert from './ConfirmationAlert';
const ResetPassword = (props) => {
const { email, intl, status } = props;
return (
<div className="form-group">
<h6 aria-level="3">
<FormattedMessage
id="account.settings.editable.field.password.reset.label"
defaultMessage="Password"
description="The password label in account settings"
/>
</h6>
<p>
<StatefulButton
className="btn-link"
state={status}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (status === 'pending') {
e.preventDefault();
}
props.resetPassword(email);
}}
disabledStates={[]}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.password.reset.button']),
}}
/>
</p>
{status === 'complete' ? <ConfirmationAlert email={email} /> : null}
</div>
);
};
ResetPassword.propTypes = {
email: PropTypes.string,
intl: intlShape.isRequired,
resetPassword: PropTypes.func.isRequired,
status: PropTypes.string,
};
ResetPassword.defaultProps = {
email: '',
status: null,
};
const mapStateToProps = state => state.accountSettings.resetPassword;
export default connect(
mapStateToProps,
{
resetPassword,
},
)(injectIntl(ResetPassword));

View File

@@ -0,0 +1,20 @@
import { AsyncActionType } from '../../data/utils';
export const RESET_PASSWORD = new AsyncActionType('ACCOUNT_SETTINGS', 'RESET_PASSWORD');
export const resetPassword = email => ({
type: RESET_PASSWORD.BASE,
payload: { email },
});
export const resetPasswordBegin = () => ({
type: RESET_PASSWORD.BEGIN,
});
export const resetPasswordSuccess = () => ({
type: RESET_PASSWORD.SUCCESS,
});
export const resetPasswordReset = () => ({
type: RESET_PASSWORD.RESET,
});

View File

@@ -0,0 +1,27 @@
import { RESET_PASSWORD } from './actions';
export const defaultState = {
status: null,
};
const reducer = (state = defaultState, action = null) => {
if (action !== null) {
switch (action.type) {
case RESET_PASSWORD.BEGIN:
return {
...state,
status: 'pending',
};
case RESET_PASSWORD.SUCCESS:
return {
...state,
status: 'complete',
};
default:
}
}
return state;
};
export default reducer;

View File

@@ -0,0 +1,14 @@
import { put, call, takeEvery } from 'redux-saga/effects';
import { resetPasswordBegin, resetPasswordSuccess, RESET_PASSWORD } from './actions';
import { postResetPassword } from './service';
function* handleResetPassword(action) {
yield put(resetPasswordBegin());
const response = yield call(postResetPassword, action.payload.email);
yield put(resetPasswordSuccess(response));
}
export default function* saga() {
yield takeEvery(RESET_PASSWORD.BASE, handleResetPassword);
}

View File

@@ -0,0 +1,21 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { handleRequestError } from '../../data/utils';
// eslint-disable-next-line import/prefer-default-export
export async function postResetPassword(email) {
const { data } = await getAuthenticatedHttpClient()
.post(
`${getConfig().LMS_BASE_URL}/password_reset/`,
formurlencoded({ email }),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
)
.catch(handleRequestError);
return data;
}

View File

@@ -0,0 +1,4 @@
export { default } from './ResetPassword';
export { default as reducer } from './data/reducers';
export { RESET_PASSWORD } from './data/actions';
export { default as saga } from './data/sagas';

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.editable.field.password.reset.button': {
id: 'account.settings.editable.field.password.reset.button',
defaultMessage: 'Reset Password',
description: 'The password reset button in account settings',
},
});
export default messages;

View File

@@ -0,0 +1,25 @@
import { AsyncActionType } from '../data/utils';
export const FETCH_SITE_LANGUAGES = new AsyncActionType('SITE_LANGUAGE', 'FETCH_SITE_LANGUAGES');
export const fetchSiteLanguages = () => ({
type: FETCH_SITE_LANGUAGES.BASE,
});
export const fetchSiteLanguagesBegin = () => ({
type: FETCH_SITE_LANGUAGES.BEGIN,
});
export const fetchSiteLanguagesSuccess = siteLanguageList => ({
type: FETCH_SITE_LANGUAGES.SUCCESS,
payload: { siteLanguageList },
});
export const fetchSiteLanguagesFailure = error => ({
type: FETCH_SITE_LANGUAGES.FAILURE,
payload: { error },
});
export const fetchSiteLanguagesReset = () => ({
type: FETCH_SITE_LANGUAGES.RESET,
});

View File

@@ -0,0 +1,74 @@
const siteLanguageList = [
{
code: 'en',
name: 'English',
released: true,
},
{
code: 'ar',
name: 'العربية',
released: true,
},
{
code: 'ca',
name: 'Català',
released: false,
},
{
code: 'es-419',
name: 'Español (Latinoamérica)',
released: true,
},
{
code: 'fr',
name: 'Français',
released: true,
},
{
code: 'he',
name: 'עברית',
released: false,
},
{
code: 'id',
name: 'Bahasa Indonesia',
released: false,
},
{
code: 'ko-kr',
name: '한국어 (대한민국)',
released: false,
},
{
code: 'pl',
name: 'Polski',
released: false,
},
{
code: 'pt-br',
name: 'Português (Brasil)',
released: false,
},
{
code: 'ru',
name: 'Русский',
released: false,
},
{
code: 'th',
name: 'ไทย',
released: false,
},
{
code: 'uk',
name: 'Українська',
released: false,
},
{
code: 'zh-cn',
name: '中文 (简体)',
released: true,
},
];
export default siteLanguageList;

View File

@@ -0,0 +1,9 @@
export { default as reducer } from './reducers';
export { default as saga } from './sagas';
export {
getSiteLanguageList,
patchPreferences,
postSetLang,
} from './service';
export { siteLanguageOptionsSelector, siteLanguageListSelector } from './selectors';
export { fetchSiteLanguages, FETCH_SITE_LANGUAGES } from './actions';

View File

@@ -0,0 +1,48 @@
import { FETCH_SITE_LANGUAGES } from './actions';
export const defaultState = {
loading: false,
loaded: false,
loadingError: null,
siteLanguageList: [],
};
const reducer = (state = defaultState, action = null) => {
if (action !== null) {
switch (action.type) {
case FETCH_SITE_LANGUAGES.BEGIN:
return {
...state,
loading: true,
loaded: false,
loadingError: null,
};
case FETCH_SITE_LANGUAGES.SUCCESS:
return {
...state,
siteLanguageList: action.payload.siteLanguageList,
loading: false,
loaded: true,
loadingError: null,
};
case FETCH_SITE_LANGUAGES.FAILURE:
return {
...state,
loading: false,
loaded: false,
loadingError: action.payload.error,
};
case FETCH_SITE_LANGUAGES.RESET:
return {
...state,
loading: false,
loaded: false,
loadingError: null,
};
default:
}
}
return state;
};
export default reducer;

View File

@@ -0,0 +1,25 @@
import { call, put, takeEvery } from 'redux-saga/effects';
import {
fetchSiteLanguagesBegin,
fetchSiteLanguagesSuccess,
fetchSiteLanguagesFailure,
FETCH_SITE_LANGUAGES,
} from './actions';
import { getSiteLanguageList } from './service';
import { handleFailure } from '../data/utils';
function* handleFetchSiteLanguages() {
try {
yield put(fetchSiteLanguagesBegin());
const siteLanguageList = yield call(getSiteLanguageList);
yield put(fetchSiteLanguagesSuccess(siteLanguageList));
} catch (e) {
yield call(handleFailure, e, fetchSiteLanguagesFailure);
}
}
export default function* saga() {
yield takeEvery(FETCH_SITE_LANGUAGES.BASE, handleFetchSiteLanguages);
}

View File

@@ -0,0 +1,20 @@
import { createSelector } from 'reselect';
import { getModuleState } from '../data/utils';
export const storePath = ['accountSettings', 'siteLanguage'];
const siteLanguageSelector = state => getModuleState(state, storePath);
export const siteLanguageListSelector = createSelector(
siteLanguageSelector,
siteLanguage => siteLanguage.siteLanguageList,
);
export const siteLanguageOptionsSelector = createSelector(
siteLanguageSelector,
siteLanguage =>
siteLanguage.siteLanguageList.map(({ code, name }) => ({
value: code,
label: name,
})),
);

View File

@@ -0,0 +1,32 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import siteLanguageList from './constants';
import { snakeCaseObject, convertKeyNames } from '../data/utils';
export async function getSiteLanguageList() {
return siteLanguageList;
}
export async function patchPreferences(username, params) {
let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, {
pref_lang: 'pref-lang',
});
await getAuthenticatedHttpClient()
.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.
}
export async function postSetLang(code) {
const formData = new FormData();
formData.append('language', code);
await getAuthenticatedHttpClient()
.post(`${getConfig().LMS_BASE_URL}/i18n/setlang/`, formData, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
}

View File

@@ -0,0 +1,144 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, StatefulButton } from '@edx/paragon';
import Alert from '../Alert';
import { disconnectAuth } from './data/actions';
class ThirdPartyAuth extends Component {
onClickDisconnect = (e) => {
e.preventDefault();
const providerId = e.currentTarget.getAttribute('data-provider-id');
if (this.props.disconnectionStatuses[providerId] === 'pending') return;
const disconnectUrl = e.currentTarget.getAttribute('data-disconnect-url');
this.props.disconnectAuth(disconnectUrl, providerId);
}
renderUnconnectedProvider(url, name) {
return (
<React.Fragment>
<h6 aria-level="3">{name}</h6>
<Hyperlink destination={url} className="btn btn-outline-primary">
<FormattedMessage
id="account.settings.sso.link.account"
defaultMessage="Sign in with {name}"
description="An action link to link a connected third party account.m {name} will be Google, Facebook, etc."
values={{ name }}
/>
</Hyperlink>
</React.Fragment>
);
}
renderConnectedProvider(url, name, id) {
const hasError = this.props.errors[id];
return (
<React.Fragment>
<h6 aria-level="3">
{name}
<span className="small font-weight-normal text-muted ml-2">
<FormattedMessage
id="account.settings.sso.account.connected"
defaultMessage="Linked"
description="A badge to show that a third party account is linked"
/>
</span>
</h6>
{hasError ? (
<Alert className="alert-danger">
<FormattedMessage
id="account.settings.sso.account.disconnect.error"
defaultMessage="There was a problem disconnecting this account. Contact support if the problem persists."
description="A message displayed when an error occurred while disconnecting a third party account"
/>
</Alert>
) : null}
<StatefulButton
className="btn-link"
state={this.props.disconnectionStatuses[id]}
labels={{
default: (
<FormattedMessage
id="account.settings.sso.unlink.account"
defaultMessage="Unlink {name} account"
description="An action link to unlink a connected third party account"
values={{ name }}
/>
),
}}
onClick={this.onClickDisconnect}
disabledStates={[]}
data-disconnect-url={url}
data-provider-id={id}
/>
</React.Fragment>
);
}
renderProvider({
name, disconnectUrl, connectUrl, connected, id,
}) {
return (
<div className="form-group" key={id}>
{
connected ?
this.renderConnectedProvider(disconnectUrl, name, id) :
this.renderUnconnectedProvider(connectUrl, name)
}
</div>
);
}
renderNoProviders() {
return (
<FormattedMessage
id="account.settings.sso.no.providers"
defaultMessage="No accounts can be linked at this time."
description="Displayed when no third party accounts are available to link an edX account to"
/>
);
}
render() {
if (this.props.providers === undefined) return null;
if (this.props.providers.length === 0) {
return this.renderNoProviders();
}
return this.props.providers.map(this.renderProvider, this);
}
}
ThirdPartyAuth.propTypes = {
providers: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
disconnectUrl: PropTypes.string,
connectUrl: PropTypes.string,
connected: PropTypes.bool,
id: PropTypes.string,
})),
disconnectionStatuses: PropTypes.objectOf(PropTypes.oneOf([null, 'pending', 'complete', 'error'])),
errors: PropTypes.objectOf(PropTypes.bool),
disconnectAuth: PropTypes.func.isRequired,
};
ThirdPartyAuth.defaultProps = {
providers: undefined,
disconnectionStatuses: {},
errors: {},
};
const mapStateToProps = state => state.accountSettings.thirdPartyAuth;
export default connect(
mapStateToProps,
{
disconnectAuth,
},
)(ThirdPartyAuth);

View File

@@ -0,0 +1,20 @@
import { AsyncActionType } from '../../data/utils';
export const DISCONNECT_AUTH = new AsyncActionType('ACCOUNT_SETTINGS', 'DISCONNECT_AUTH');
export const disconnectAuth = (url, providerId) => ({
type: DISCONNECT_AUTH.BASE, payload: { url, providerId },
});
export const disconnectAuthBegin = providerId => ({
type: DISCONNECT_AUTH.BEGIN, payload: { providerId },
});
export const disconnectAuthSuccess = (providerId, thirdPartyAuthProviders) => ({
type: DISCONNECT_AUTH.SUCCESS,
payload: { providerId, thirdPartyAuthProviders },
});
export const disconnectAuthFailure = providerId => ({
type: DISCONNECT_AUTH.FAILURE, payload: { providerId },
});
export const disconnectAuthReset = providerId => ({
type: DISCONNECT_AUTH.RESET, payload: { providerId },
});

View File

@@ -0,0 +1,59 @@
import { DISCONNECT_AUTH } from './actions';
export const defaultState = {
providers: [],
disconnectionStatuses: {},
errors: {},
};
const reducer = (state = defaultState, action = null) => {
if (action !== null) {
switch (action.type) {
case DISCONNECT_AUTH.BEGIN:
return {
...state,
disconnectionStatuses: {
...state.disconnectionStatuses,
[action.payload.providerId]: 'pending',
},
};
case DISCONNECT_AUTH.SUCCESS:
return {
...state,
disconnectionStatuses: {
...state.disconnectionStatuses,
[action.payload.providerId]: 'complete',
},
providers: action.payload.thirdPartyAuthProviders,
};
case DISCONNECT_AUTH.FAILURE:
return {
...state,
disconnectionStatuses: {
...state.disconnectionStatuses,
[action.payload.providerId]: 'error',
},
errors: {
...state.errors,
[action.payload.providerId]: true,
},
};
case DISCONNECT_AUTH.RESET:
return {
...state,
disconnectionStatuses: {
...state.disconnectionStatuses,
[action.payload.providerId]: null,
},
errors: {
...state.errors,
[action.payload.providerId]: null,
},
};
default:
}
}
return state;
};
export default reducer;

View File

@@ -0,0 +1,33 @@
import { call, put, takeEvery } from 'redux-saga/effects';
import { logError } from '@edx/frontend-platform/logging';
import {
disconnectAuthReset,
disconnectAuthBegin,
disconnectAuthSuccess,
disconnectAuthFailure,
DISCONNECT_AUTH,
} from './actions';
import {
getThirdPartyAuthProviders,
postDisconnectAuth,
} from './service';
function* handleDisconnectAuth(action) {
const { providerId } = action.payload;
try {
yield put(disconnectAuthReset(providerId));
yield put(disconnectAuthBegin(providerId));
yield call(postDisconnectAuth, action.payload.url);
const thirdPartyAuthProviders = yield call(getThirdPartyAuthProviders);
yield put(disconnectAuthSuccess(providerId, thirdPartyAuthProviders));
} catch (e) {
logError(e);
yield put(disconnectAuthFailure(providerId));
}
}
export default function* saga() {
yield takeEvery(DISCONNECT_AUTH.BASE, handleDisconnectAuth);
}

View File

@@ -0,0 +1,23 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { handleRequestError } from '../../data/utils';
export async function getThirdPartyAuthProviders() {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/third_party_auth/v0/providers/user_status`)
.catch(handleRequestError);
return data.map(({ connect_url: connectUrl, disconnect_url: disconnectUrl, ...provider }) => ({
...provider,
connectUrl: `${getConfig().LMS_BASE_URL}${connectUrl}`,
disconnectUrl: `${getConfig().LMS_BASE_URL}${disconnectUrl}`,
}));
}
export async function postDisconnectAuth(url) {
const { data } = await getAuthenticatedHttpClient()
.post(url)
.catch(handleRequestError);
return data;
}

View File

@@ -0,0 +1,5 @@
export { default } from './ThirdPartyAuth';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { getThirdPartyAuthProviders, postDisconnectAuth } from './data/service';
export { DISCONNECT_AUTH } from './data/actions';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1168px" height="540px" viewBox="0 0 1168 540" 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>logo</title>
<desc>Created with Sketch.</desc>
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -1,11 +0,0 @@
import { fetchUserAccount as _fetchUserAccount, UserAccountApiService } from '@edx/frontend-auth';
let userAccountApiService = null;
export function configureUserAccountApiService(configuration, apiClient) {
userAccountApiService = new UserAccountApiService(apiClient, configuration.LMS_BASE_URL);
}
export function fetchUserAccount(username) {
return _fetchUserAccount(userAccountApiService, username);
}

View File

@@ -1,10 +0,0 @@
import * as utils from './utils';
import PageLoading from './components/PageLoading';
import { configureUserAccountApiService, fetchUserAccount } from './actions';
export {
PageLoading,
utils,
configureUserAccountApiService,
fetchUserAccount,
};

View File

@@ -1,234 +0,0 @@
import React, { Component } from 'react';
import { connect, Provider } from 'react-redux';
import PropTypes from 'prop-types';
import { IntlProvider, injectIntl, intlShape } from 'react-intl';
import { Route, Switch } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import { sendTrackEvent } from '@edx/frontend-analytics';
import SiteHeader from '@edx/frontend-component-site-header';
import SiteFooter from '@edx/frontend-component-footer';
import { getLocale, getMessages } from '@edx/frontend-i18n'; // eslint-disable-line
import { PageLoading, fetchUserAccount } from '../common';
import { ConnectedExamplePage } from '../example';
import FooterLogo from '../assets/edx-footer.png';
import HeaderLogo from '../assets/logo.svg';
import ErrorPage from './ErrorPage';
import NotFoundPage from './NotFoundPage';
import messages from './App.messages';
import WelcomePage from './WelcomePage';
function PageContent({
ready,
configuration,
username,
avatar,
intl,
}) {
if (!ready) {
return <PageLoading srMessage={intl.formatMessage(messages['app.loading.message'])} />;
}
const mainMenu = [
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/course`,
content: intl.formatMessage(messages['siteheader.links.courses']),
},
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/course?program=all`,
content: intl.formatMessage(messages['siteheader.links.programs']),
},
{
type: 'item',
href: `${process.env.MARKETING_SITE_BASE_URL}/schools-partners`,
content: intl.formatMessage(messages['siteheader.links.schools']),
},
];
const userMenu = [
{
type: 'item',
href: `${process.env.LMS_BASE_URL}`,
content: intl.formatMessage(messages['siteheader.user.menu.dashboard']),
},
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/u/${username}`,
content: intl.formatMessage(messages['siteheader.user.menu.profile']),
},
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/account/settings`,
content: intl.formatMessage(messages['siteheader.user.menu.account.settings']),
},
{
type: 'item',
href: process.env.LOGOUT_URL,
content: intl.formatMessage(messages['siteheader.user.menu.logout']),
},
];
const loggedOutItems = [
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/login`,
content: intl.formatMessage(messages['siteheader.user.menu.login']),
},
{
type: 'item',
href: `${process.env.LMS_BASE_URL}/register`,
content: intl.formatMessage(messages['siteheader.user.menu.register']),
},
];
return (
<div id="app">
<SiteHeader
logo={HeaderLogo}
loggedIn
username={username}
avatar={avatar}
logoAltText={configuration.SITE_NAME}
logoDestination={configuration.MARKETING_SITE_BASE_URL}
mainMenu={mainMenu}
userMenu={userMenu}
loggedOutItems={loggedOutItems}
/>
<main>
<Switch>
<Route path="/example" component={ConnectedExamplePage} />
<Route path="/error" component={ErrorPage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="/" component={WelcomePage} />
<Route path="*" component={NotFoundPage} />
</Switch>
</main>
<SiteFooter
siteName={configuration.SITE_NAME}
siteLogo={FooterLogo}
marketingSiteBaseUrl={configuration.MARKETING_SITE_BASE_URL}
supportUrl={configuration.SUPPORT_URL}
contactUrl={configuration.CONTACT_URL}
openSourceUrl={configuration.OPEN_SOURCE_URL}
termsOfServiceUrl={configuration.TERMS_OF_SERVICE_URL}
privacyPolicyUrl={configuration.PRIVACY_POLICY_URL}
facebookUrl={configuration.FACEBOOK_URL}
twitterUrl={configuration.TWITTER_URL}
youTubeUrl={configuration.YOU_TUBE_URL}
linkedInUrl={configuration.LINKED_IN_URL}
googlePlusUrl={configuration.GOOGLE_PLUS_URL}
redditUrl={configuration.REDDIT_URL}
appleAppStoreUrl={configuration.APPLE_APP_STORE_URL}
googlePlayUrl={configuration.GOOGLE_PLAY_URL}
handleAllTrackEvents={sendTrackEvent}
/>
</div>
);
}
PageContent.propTypes = {
username: PropTypes.string.isRequired,
avatar: PropTypes.string,
ready: PropTypes.bool,
configuration: PropTypes.shape({
SITE_NAME: PropTypes.string.isRequired,
MARKETING_SITE_BASE_URL: PropTypes.string.isRequired,
SUPPORT_URL: PropTypes.string.isRequired,
CONTACT_URL: PropTypes.string.isRequired,
OPEN_SOURCE_URL: PropTypes.string.isRequired,
TERMS_OF_SERVICE_URL: PropTypes.string.isRequired,
PRIVACY_POLICY_URL: PropTypes.string.isRequired,
FACEBOOK_URL: PropTypes.string.isRequired,
TWITTER_URL: PropTypes.string.isRequired,
YOU_TUBE_URL: PropTypes.string.isRequired,
LINKED_IN_URL: PropTypes.string.isRequired,
GOOGLE_PLUS_URL: PropTypes.string.isRequired,
REDDIT_URL: PropTypes.string.isRequired,
APPLE_APP_STORE_URL: PropTypes.string.isRequired,
GOOGLE_PLAY_URL: PropTypes.string.isRequired,
}).isRequired,
intl: intlShape.isRequired,
};
PageContent.defaultProps = {
ready: false,
avatar: null,
};
const IntlPageContent = injectIntl(PageContent);
class App extends Component {
componentDidMount() {
const { username } = this.props;
this.props.fetchUserAccount(username);
}
render() {
return (
<IntlProvider locale={getLocale()} messages={getMessages()}>
<Provider store={this.props.store}>
<ConnectedRouter history={this.props.history}>
<IntlPageContent
ready={this.props.ready}
configuration={this.props.configuration}
username={this.props.username}
avatar={this.props.avatar}
/>
</ConnectedRouter>
</Provider>
</IntlProvider>
);
}
}
App.propTypes = {
fetchUserAccount: PropTypes.func.isRequired,
username: PropTypes.string.isRequired,
avatar: PropTypes.string,
store: PropTypes.object.isRequired, // eslint-disable-line
history: PropTypes.object.isRequired, // eslint-disable-line
ready: PropTypes.bool,
configuration: PropTypes.shape({
SITE_NAME: PropTypes.string.isRequired,
MARKETING_SITE_BASE_URL: PropTypes.string.isRequired,
SUPPORT_URL: PropTypes.string.isRequired,
CONTACT_URL: PropTypes.string.isRequired,
OPEN_SOURCE_URL: PropTypes.string.isRequired,
TERMS_OF_SERVICE_URL: PropTypes.string.isRequired,
PRIVACY_POLICY_URL: PropTypes.string.isRequired,
FACEBOOK_URL: PropTypes.string.isRequired,
TWITTER_URL: PropTypes.string.isRequired,
YOU_TUBE_URL: PropTypes.string.isRequired,
LINKED_IN_URL: PropTypes.string.isRequired,
GOOGLE_PLUS_URL: PropTypes.string.isRequired,
REDDIT_URL: PropTypes.string.isRequired,
APPLE_APP_STORE_URL: PropTypes.string.isRequired,
GOOGLE_PLAY_URL: PropTypes.string.isRequired,
}).isRequired,
};
App.defaultProps = {
ready: false,
avatar: null,
};
const mapStateToProps = state => ({
username: state.authentication.username,
// An error means that we tried to load the user account and failed,
// which also means we're ready to display something.
ready: state.userAccount.loaded || state.userAccount.error != null,
configuration: state.configuration,
avatar: state.userAccount.profileImage.hasImage
? state.userAccount.profileImage.imageUrlMedium
: null,
});
export default connect(
mapStateToProps,
{
fetchUserAccount,
},
)(App);

View File

@@ -1,56 +0,0 @@
import { defineMessages } from 'react-intl';
const messages = defineMessages({
'siteheader.links.courses': {
id: 'siteheader.links.courses',
defaultMessage: 'Courses',
description: 'Link to the course catalog',
},
'siteheader.links.programs': {
id: 'siteheader.links.programs',
defaultMessage: 'Programs & Degrees',
description: 'Link to the programs catalog',
},
'siteheader.links.schools': {
id: 'siteheader.links.schools',
defaultMessage: 'Schools & Partners',
description: 'Link to the schools and partners landing page',
},
'siteheader.user.menu.dashboard': {
id: 'siteheader.user.menu.dashboard',
defaultMessage: 'Dashboard',
description: 'Link to the user dashboard',
},
'siteheader.user.menu.profile': {
id: 'siteheader.user.menu.profile',
defaultMessage: 'Profile',
description: 'Link to the user profile',
},
'siteheader.user.menu.account.settings': {
id: 'siteheader.user.menu.account.settings',
defaultMessage: 'Account',
description: 'Link to account settings',
},
'siteheader.user.menu.logout': {
id: 'siteheader.user.menu.logout',
defaultMessage: 'Logout',
description: 'Logout link',
},
'siteheader.user.menu.login': {
id: 'siteheader.user.menu.login',
defaultMessage: 'Login',
description: 'Login link',
},
'siteheader.user.menu.register': {
id: 'siteheader.user.menu.register',
defaultMessage: 'Sign Up',
description: 'Link to registration',
},
'app.loading.message': {
id: 'app.loading.message',
defaultMessage: 'Loading',
description: 'Message shown when page content is loading.',
},
});
export default messages;

View File

@@ -1,20 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
export default function ErrorPage() {
return (
<div className="container-fluid py-5 justify-content-center align-items-start text-center">
<div className="row">
<div className="col">
<p className="my-0 py-5 text-muted">
<FormattedMessage
id="error.unexpected.message"
defaultMessage="An unexpected error occurred."
description="error message when an unexpected error occurs"
/>
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import React from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
export default function WelcomePage() {
return (
<div className="py-5 justify-content-center align-items-start text-center">
<p className="my-0 pt-5 text-muted">
<FormattedMessage
id="app.page"
defaultMessage="Congratulations! You have a new micro-frontend."
description="Default page content for a new frontend application"
/>
</p>
<p className="my-0 pt-3 text-muted">
<Link to="/example">
<FormattedMessage
id="app.example.link"
defaultMessage="Click here to visit a example page that loads data."
description="A link to an example page that loads data."
/>
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { getConfig } from '@edx/frontend-platform';
import { applyMiddleware, createStore, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension';
import { createLogger } from 'redux-logger';
import createSagaMiddleware from 'redux-saga';
import createRootReducer from './reducers';
import rootSaga from './sagas';
const sagaMiddleware = createSagaMiddleware();
function composeMiddleware() {
if (getConfig().ENVIRONMENT === 'development') {
const loggerMiddleware = createLogger({
collapsed: true,
});
return composeWithDevTools(applyMiddleware(thunkMiddleware, sagaMiddleware, loggerMiddleware));
}
return compose(applyMiddleware(thunkMiddleware, sagaMiddleware));
}
export default function configureStore(initialState = {}) {
const store = createStore(
createRootReducer(),
initialState,
composeMiddleware(),
);
sagaMiddleware.run(rootSaga);
return store;
}

11
src/data/reducers.js Executable file
View File

@@ -0,0 +1,11 @@
import { combineReducers } from 'redux';
import {
reducer as accountSettingsReducer,
storeName as accountSettingsStoreName,
} from '../account-settings';
const createRootReducer = () => combineReducers({
[accountSettingsStoreName]: accountSettingsReducer,
});
export default createRootReducer;

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