Compare commits

..

84 Commits

Author SHA1 Message Date
Kyle McCormick
de2a751e36 Fix logout URL 2020-04-15 11:43:01 -04:00
Kyle McCormick
0375ebecda Use devstack frontend-build branch; add dev-build command 2020-04-15 11:43:00 -04:00
Kyle McCormick
f088e2783b Update .env.development for devstack 2020-04-15 11:43:00 -04:00
Kyle McCormick
abc29bbcee Make React routes relative 2020-04-15 11:43:00 -04:00
Zaman Afzal
0b00fa21a2 ENT-2651 Recovery email Field UX logic on Account Settings page was not same to Dashboard (#206)
* ENT-2651 Recovery email Field UX logic on Account Settings page was not same to Dashboard
2020-04-08 15:36:40 +05:00
edX Transifex Bot
1aa6a22c1c fix(i18n): update translations 2020-04-05 17:06:49 -04:00
Matt Tuchfarber
d3d0bf97b6 Merge pull request #205 from edx/aj/MICROBA-234_MB_phone
Add coaching consent form
2020-04-02 14:22:48 -04:00
Albert (AJ) St. Aubin
7361674019 Created the form to collect phone number and name for coaching. 2020-04-02 13:53:36 -04:00
Renovate Bot
10a3f1fb35 fix(deps): update dependency @edx/frontend-component-footer to v10.0.9 2020-03-23 17:44:10 +00:00
Renovate Bot
06d018fc62 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.28 2020-03-23 16:19:03 +00:00
edX Transifex Bot
3e5bf2b19a fix(i18n): update translations 2020-03-22 17:06:24 -04:00
adeel khan
724a7f9201 Merge pull request #198 from edx/adeel/prod_1349_fix_timezone_dropdown
Fix timezone dropdown under Site Preferences
2020-03-20 00:59:10 +05:00
Adeel Khan
66b27a01d0 Fix timezone dropdown under Site Preferences
This patch would fix timezone dropdown which
doesn't change  value on selection.

PROD-1349
2020-03-19 22:52:38 +05:00
Thomas Tracy
9c9725c86c Bypass key check until we are ready to add it to internal (#197) 2020-03-16 13:59:52 -04:00
Thomas Tracy
bc8d41cd66 Remove coaching environment variable (#196)
Since we are not yet setting this in production, we need to remove it
from .env to prevent the page from building 'null' as its value
2020-03-16 12:51:47 -04:00
Thomas Tracy
3a49fb3296 Fix 404 error for coaching (#195) 2020-03-16 11:52:45 -04:00
Thomas Tracy
c1d1af4943 Add phone number field and coaching consent toggle (#192)
* 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.

* fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.9

* fix(deps): update dependency @edx/frontend-component-footer to v10.0.8

* 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.

* Made requested changes and additional fixes

* Requested changes

Co-authored-by: Renovate Bot <bot@renovateapp.com>
2020-03-16 10:55:36 -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
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
115 changed files with 13852 additions and 13462 deletions

View File

@@ -1,41 +0,0 @@
{
"presets": [
[
"env",
{
"targets": {
"browsers": ["last 2 versions", "ie 11"]
}
}
],
"babel-preset-react"
],
"plugins": [
"transform-object-rest-spread",
"transform-class-properties",
["transform-imports", {
"@fortawesome/free-brands-svg-icons": {
"transform": "@fortawesome/free-brands-svg-icons/${member}",
"skipDefaultConversion": true
},
"@fortawesome/free-regular-svg-icons": {
"transform": "@fortawesome/free-regular-svg-icons/${member}",
"skipDefaultConversion": true
},
"@fortawesome/free-solid-svg-icons": {
"transform": "@fortawesome/free-solid-svg-icons/${member}",
"skipDefaultConversion": true
}
}]
],
"env": {
"i18n": {
"plugins": [
["react-intl", {
"messagesDir": "./temp/babel-plugin-react-intl",
"moduleSourceName": "@edx/frontend-i18n"
}]
]
}
}
}

17
.env Normal file
View File

@@ -0,0 +1,17 @@
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

20
.env.development Normal file
View File

@@ -0,0 +1,20 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:19000/account/'
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/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
NODE_ENV='development'
ORDER_HISTORY_URL='localhost:19000/ecommerce/orders'
PORT=1997 # For standalone dev server only.
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=''

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

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,23 +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 i18n_extract
- npm run lint
- npm run test
- npm run build
- npm run is-es5
- 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,42 +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
------------
React app for account settings.
Get Started
Development
-----------
1. Start up your local devstack
2. If you don't have node installed. Install Node
3. In the project directory: npm install
4. Then run npm start
5. Open your browser to http://localhost:1997/account-settings
Start Devstack
^^^^^^^^^^^^^^
Important Note
--------------
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
The production Webpack configuration for this repo uses `Purgecss <https://www.purgecss.com/>`_
to remove unused CSS from the production css file. In webpack/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 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.
- 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

View File

@@ -0,0 +1,30 @@
1. Add Coaching Consent
--------------------------------
Status
------
Accepted
Context
-------
We need to provide users who are eligible for coaching with both an always available
coaching toggle and a one-time form they can view to signup for coaching.
Decision
--------
While the coaching functionality is currently both limited, closed source, and the form
exists outside of the standard design of this MFE, it was decided to add it here as a
temporary measure due to it being at it's core, an account setting.
The longer term solutions include either:
- using the frontend plugins feature when they become available to inject our coaching
work into the account MFE
- roll it into it's own MFE if enough additional coaching frontend work is required
Consequences
------------
Code will exist inside this Open edX MFE that integrates with a closed source app.

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}

22840
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,89 @@
{
"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",
"dev-build": "fedx-scripts webpack-dev",
"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.2.1",
"@edx/frontend-analytics": "^2.0.0",
"@edx/frontend-auth": "^5.3.4",
"@edx/frontend-component-footer": "^6.0.2",
"@edx/frontend-component-site-header": "^2.4.0",
"@edx/frontend-i18n": "^2.1.0",
"@edx/frontend-logging": "^2.0.2",
"@edx/paragon": "^4.2.6",
"@fortawesome/fontawesome-svg-core": "^1.2.18",
"@fortawesome/free-brands-svg-icons": "^5.8.2",
"@fortawesome/free-regular-svg-icons": "^5.7.1",
"@fortawesome/free-solid-svg-icons": "^5.8.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",
"formdata-polyfill": "^3.0.18",
"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.findindex": "^4.6.0",
"lodash.get": "^4.4.2",
"lodash.isempty": "^4.4.0",
"lodash.omit": "^4.5.0",
"lodash.pick": "^4.4.0",
"lodash.snakecase": "^4.1.1",
"memoize-one": "^5.0.4",
"newrelic": "^5.5.0",
"prop-types": "^15.5.10",
"react": "^16.8.3",
"react-dom": "^16.8.3",
"react-redux": "^5.1.1",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-router-hash-link": "^1.2.1",
"react-scrollspy": "^3.4.0",
"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",
"url-polyfill": "^1.1.5"
"@edx/frontend-component-footer": "10.0.9",
"@edx/frontend-component-header": "2.0.5",
"@edx/frontend-platform": "1.1.14",
"@edx/paragon": "7.1.5",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.8.2",
"@fortawesome/free-regular-svg-icons": "5.7.2",
"@fortawesome/free-solid-svg-icons": "5.8.2",
"@fortawesome/react-fontawesome": "0.1.9",
"babel-polyfill": "6.26.0",
"classnames": "2.2.6",
"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-imports": "^1.5.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": "^5.0.0",
"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",
"new-relic-source-map-webpack-plugin": "1.1.0",
"node-sass": "^4.7.2",
"postcss-loader": "^3.0.0",
"postcss-rtl": "^1.3.3",
"purgecss-webpack-plugin": "^1.5.0",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.8.6",
"@edx/frontend-build": "github:edx/frontend-build#kdmccormick/devstack-frontends",
"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",
"source-map-loader": "^0.2.4",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"url-loader": "^1.1.2",
"webpack": "^4.25.1",
"webpack-bundle-analyzer": "^3.3.2",
"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"
},
"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

@@ -1,21 +1,25 @@
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,
} from '@edx/frontend-i18n';
getCountryList,
getLanguageList,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import messages from './AccountSettingsPage.messages';
import { fetchSettings, saveSettings, updateDraft } from './actions';
import { accountSettingsPageSelector } from './selectors';
import { Alert, PageLoading } from '../common';
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';
@@ -27,40 +31,40 @@ import {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
GENDER_OPTIONS,
} from './constants';
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import CoachingToggle from './coaching/CoachingToggle';
class AccountSettingsPage extends React.Component {
constructor(props) {
super(props);
this.educationLevels = EDUCATION_LEVELS.map(key => ({
value: key,
label: props.intl.formatMessage(messages[`account.settings.field.education.levels.${key || 'empty'}`]),
}));
this.genderOptions = GENDER_OPTIONS.map(key => ({
value: key,
label: props.intl.formatMessage(messages[`account.settings.field.gender.options.${key || 'empty'}`]),
}));
this.languageProficiencyOptions = [{
value: '',
label: props.intl.formatMessage(messages['account.settings.field.language_proficiencies.options.empty']),
}].concat(props.languageProficiencyOptions);
this.yearOfBirthOptions = [{
value: '',
label: props.intl.formatMessage(messages['account.settings.field.year_of_birth.options.empty']),
}].concat(YEAR_OF_BIRTH_OPTIONS);
this.countryOptions = [{
value: '',
label: props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(props.countryOptions);
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,
});
}
getTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions) => {
// 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: '',
@@ -78,6 +82,29 @@ class AccountSettingsPage extends React.Component {
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);
}
@@ -97,7 +124,7 @@ class AccountSettingsPage extends React.Component {
};
renderDuplicateTpaProviderMessage() {
if (!this.props.duplicateTpaProvider) {
if (!this.state.duplicateTpaProvider) {
return null;
}
@@ -109,7 +136,7 @@ class AccountSettingsPage extends React.Component {
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.props.duplicateTpaProvider}</b>,
provider: <b>{this.state.duplicateTpaProvider}</b>,
}}
/>
</Alert>
@@ -132,7 +159,7 @@ class AccountSettingsPage extends React.Component {
values={{
managerTitle: <b>{this.props.profileDataManager}</b>,
support: (
<Hyperlink destination={this.props.supportUrl} target="_blank">
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
<FormattedMessage
id="account.settings.message.managed.settings.support"
defaultMessage="support"
@@ -157,7 +184,7 @@ class AccountSettingsPage extends React.Component {
}
renderSecondaryEmailField(editableFieldProps) {
if (this.props.hiddenFields.includes('secondary_email')) {
if (!Boolean(this.props.formValues.secondary_email_enabled)) {
return null;
}
@@ -179,9 +206,19 @@ class AccountSettingsPage extends React.Component {
onSubmit: this.handleSubmit,
};
const timeZoneOptions = this.getTimeZoneOptions(
// 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;
@@ -240,14 +277,14 @@ class AccountSettingsPage extends React.Component {
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={this.yearOfBirthOptions}
options={yearOfBirthOptions}
{...editableFieldProps}
/>
<EditableField
name="country"
type="select"
value={this.props.formValues.country}
options={this.countryOptions}
options={countryOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
emptyLabel={
this.isEditable('country') ?
@@ -268,7 +305,7 @@ class AccountSettingsPage extends React.Component {
name="level_of_education"
type="select"
value={this.props.formValues.level_of_education}
options={this.educationLevels}
options={educationLevelOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.education.empty'])}
{...editableFieldProps}
@@ -277,7 +314,7 @@ class AccountSettingsPage extends React.Component {
name="gender"
type="select"
value={this.props.formValues.gender}
options={this.genderOptions}
options={genderOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.gender.empty'])}
{...editableFieldProps}
@@ -286,11 +323,19 @@ class AccountSettingsPage extends React.Component {
name="language_proficiencies"
type="select"
value={this.props.formValues.language_proficiencies}
options={this.languageProficiencyOptions}
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">
@@ -335,7 +380,7 @@ class AccountSettingsPage extends React.Component {
name="siteLanguage"
type="select"
options={this.props.siteLanguageOptions}
value={this.props.siteLanguage.draftOrSavedValue}
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}
@@ -343,7 +388,7 @@ class AccountSettingsPage extends React.Component {
<EditableField
name="time_zone"
type="select"
value={this.props.formValues.time_zone || ''}
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'])}
@@ -366,7 +411,6 @@ class AccountSettingsPage extends React.Component {
<DeleteAccount
isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA}
logoutUrl={this.props.logoutUrl}
/>
</div>
@@ -420,6 +464,8 @@ class AccountSettingsPage extends React.Component {
}
}
AccountSettingsPage.contextType = AppContext;
AccountSettingsPage.propTypes = {
intl: intlShape.isRequired,
loading: PropTypes.bool,
@@ -437,32 +483,29 @@ AccountSettingsPage.propTypes = {
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,
draftOrSavedValue: PropTypes.string,
savedValue: PropTypes.string,
draft: PropTypes.string,
}),
siteLanguageOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
countryOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
languageProficiencyOptions: 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,
secondary_email_enabled: PropTypes.bool,
timeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
@@ -476,10 +519,7 @@ AccountSettingsPage.propTypes = {
updateDraft: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
duplicateTpaProvider: PropTypes.string,
tpaProviders: PropTypes.arrayOf(PropTypes.object),
supportUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
};
AccountSettingsPage.defaultProps = {
@@ -488,16 +528,13 @@ AccountSettingsPage.defaultProps = {
loadingError: null,
siteLanguage: null,
siteLanguageOptions: [],
countryOptions: [],
timeZoneOptions: [],
countryTimeZoneOptions: [],
languageProficiencyOptions: [],
profileDataManager: null,
staticFields: [],
hiddenFields: ['secondary_email'],
duplicateTpaProvider: null,
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
};
export default connect(accountSettingsPageSelector, {

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-i18n';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.page.heading': {

View File

@@ -1,14 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
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 './selectors';
import { betaLanguageBannerSelector } from './data/selectors';
import messages from './AccountSettingsPage.messages';
import { saveSettings } from './actions';
import { TRANSIFEX_LANGUAGE_BASE_URL } from './constants';
import { Alert } from '../common';
import { saveSettings } from './data/actions';
import { TRANSIFEX_LANGUAGE_BASE_URL } from './data/constants';
import Alert from './Alert';
class BetaLanguageBanner extends React.Component {
getSiteLanguageEntry(languageCode) {
@@ -47,7 +48,7 @@ class BetaLanguageBanner extends React.Component {
};
render() {
const savedLanguage = this.getSiteLanguageEntry(this.props.siteLanguage.savedValue);
const savedLanguage = this.getSiteLanguageEntry(this.context.locale);
const isSavedLanguageReleased = savedLanguage.released === true;
const noPreviousLanguageSet = this.props.siteLanguage.previousValue === null;
if (isSavedLanguageReleased || noPreviousLanguageSet) {
@@ -87,12 +88,13 @@ class BetaLanguageBanner extends React.Component {
}
}
BetaLanguageBanner.contextType = AppContext;
BetaLanguageBanner.propTypes = {
intl: intlShape.isRequired,
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
draftOrSavedValue: PropTypes.string,
savedValue: PropTypes.string,
draft: PropTypes.string,
}),
siteLanguageList: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),

View File

@@ -1,19 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
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 '../common';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './actions';
import { editableFieldSelector } from './selectors';
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
function EditableField(props) {

View File

@@ -1,19 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-i18n';
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, SwitchContent } from '../common';
import Alert from './Alert';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './actions';
import { editableFieldSelector } from './selectors';
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
function EmailField(props) {

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';

View File

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

View File

@@ -20,6 +20,9 @@
}
li {
margin-bottom: .5rem;
a {
text-decoration: underline;
}
}
}
@@ -29,7 +32,16 @@
}
.account-section {
// These properties together will shift the hashlink position
margin-bottom: map-get($spacers, 5);
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,293 @@
import React from 'react';
import { getConfig, getQueryParameters } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import PageLoading from '../PageLoading';
import CoachingConsentForm from './CoachingConsentForm';
import messages from './CoachingConsent.messages';
import LogoSVG from '../../logo.svg';
import { fetchSettings, saveSettings } from '../data/actions';
import { coachingConsentPageSelector } from '../data/selectors';
const Logo = ({ src, alt, ...attributes }) => (
<>
<img src={src} alt={alt} {...attributes} />
</>
);
const SuccessMessage = props => (
<div className="col-12 col-lg-6 shadow-lg mx-auto mt-4 p-5">
<FontAwesomeIcon className="text-success" icon={faCheck} size="5x" />
<div className="h3">{props.header}</div>
<div>{props.message}</div>
<Hyperlink destination={props.continueUrl} className="d-block p-2 my-3 text-center text-white bg-primary rounded">
{props.continue}
</Hyperlink>
</div>
);
const AutoRedirect = (props) => {
window.location.href = props.redirectUrl;
return <></>;
};
const VIEWS = {
NOT_LOADED: 'NOT_LOADED',
LOADED: 'LOADED',
SUCCESS: 'SUCCESS',
SUCCESS_PENDING: 'SUCCESS_PENDING',
DECLINED: 'DECLINED',
DECLINE_PENDING: 'DECLINE_PENDING',
};
class CoachingConsent extends React.Component {
constructor(props, context) {
super(props, context);
// Used to redirect back to the courseware.
const nextUrl = this.sanitizeForwardingUrl(getQueryParameters().next);
this.state = {
redirectUrl: nextUrl || `${getConfig().LMS_BASE_URL}/dashboard/`,
formErrors: {},
formSubmitted: false,
declineSubmitted: false,
allSubmissionsComplete: false,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.declineCoaching = this.declineCoaching.bind(this);
}
componentDidMount() {
this.props.fetchSettings();
}
componentDidUpdate(prevProps, prevState) {
/*
When we are submitting the form, we're calling saveSettings 3 times, which causes
multiple parallel redux flows. Because of this we can't rely on just the redux states
being sent in through props. For instance if the coaching submission and name
submission happen in near parallel, the coaching flow could return errors in
formErrors and the name flow could overwrite the formErrors with an empty object.
To minimize disruption to the rest of the app, we're going to manage flow state from
within this component.
*/
// If a new error comes in, store it before the next redux call overwrites it.
let allFormErrors = {};
let allSubmissionsComplete = false;
// Collect new errors and add to state (will be cleared on new submission)
const newErrorsFound = (
this.props.formErrors !== prevProps.formErrors
&& Object.keys(this.props.formErrors).length > 0
);
if (newErrorsFound) {
allFormErrors = Object.assign({}, this.state.formErrors, this.props.formErrors);
}
// Check if all values from the form have confirmation values
if (
this.state.formSubmitted &&
this.props.confirmationValues.coaching &&
this.props.confirmationValues.name &&
this.props.confirmationValues.phone_number
) {
allSubmissionsComplete = true;
}
// Check if all values from the decline link have confirmation values
if (this.props.confirmationValues.coaching && this.state.declineSubmitted) {
allSubmissionsComplete = true;
}
if (newErrorsFound || (allSubmissionsComplete !== prevState.allSubmissionsComplete)) {
this.setState({
formErrors: allFormErrors,
allSubmissionsComplete,
});
}
}
sanitizeForwardingUrl(url) {
// Redirect to root of MFE if invalid next param is sent
return url && url.startsWith(getConfig().LMS_BASE_URL) ? url : `${getConfig().LMS_BASE_URL}/dashboard/`;
}
async handleSubmit(e) {
e.preventDefault();
this.setState({
formErrors: {},
formSubmitted: true,
});
// Must store target values or they disappear before the async function can use them.
const fullName = e.target.fullName.value;
const phoneNumber = e.target.phoneNumber.value;
const coachingValues = this.props.formValues.coaching;
// These will overwrite each other's redux states (see componentDidUpdate note)
this.props.saveSettings('name', fullName);
this.props.saveSettings('phone_number', phoneNumber);
this.props.saveSettings('coaching', {
...coachingValues,
phone_number: phoneNumber,
coaching_consent: true,
consent_form_seen: true,
});
}
async declineCoaching(e) {
e.preventDefault();
this.setState({
formErrors: {},
declineSubmitted: true,
});
// Must store target values or they disappear before the async function can use them.
const coachingValues = this.props.formValues.coaching;
this.props.saveSettings('coaching', {
...coachingValues,
coaching_consent: false,
consent_form_seen: true,
});
}
renderView(currentView) {
switch (currentView) {
case VIEWS.NOT_LOADED:
return <PageLoading srMessage="" />;
case VIEWS.LOADED:
return (<CoachingConsentForm
onSubmit={this.handleSubmit}
declineCoaching={this.declineCoaching}
formErrors={this.state.formErrors}
formValues={this.props.formValues}
redirectUrl={this.state.redirectUrl}
/>);
case VIEWS.SUCCESS_PENDING:
return <PageLoading srMessage="Submitting..." />;
case VIEWS.SUCCESS:
return (<SuccessMessage
continueUrl={this.state.redirectUrl}
header={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.header'])}
message={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.message'])}
continue={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.continue'])}
/>);
case VIEWS.DECLINE_PENDING:
return <PageLoading srMessage="Redirecting..." />;
case VIEWS.DECLINED:
return <AutoRedirect redirectUrl={this.state.redirectUrl} />;
default:
return <></>;
}
}
render() {
const { loaded } = this.props;
const formHasErrors = Object.keys(this.state.formErrors).length > 0;
let currentView = null;
// This amount of logic was making the template very hard to read, so I broke it out into views.
if (!loaded) {
currentView = VIEWS.NOT_LOADED;
} else if (this.state.formSubmitted && !formHasErrors) {
if (this.state.allSubmissionsComplete) {
currentView = VIEWS.SUCCESS;
} else {
currentView = VIEWS.SUCCESS_PENDING;
}
} else if (this.state.declineSubmitted && !formHasErrors) {
if (this.state.allSubmissionsComplete) {
currentView = VIEWS.DECLINED;
} else {
currentView = VIEWS.DECLINE_PENDING;
}
} else {
currentView = VIEWS.LOADED;
}
return (
<main>
<div className="w-100 d-flex justify-content-center align-items-center shadow coaching-header">
<Logo
className="logo"
src={LogoSVG}
alt="Logo"
/>
</div>
{this.renderView(currentView)}
</main>
);
}
}
Logo.defaultProps = {
src: '',
alt: '',
};
Logo.propTypes = {
src: PropTypes.string,
alt: PropTypes.string,
};
SuccessMessage.defaultProps = {
header: '',
message: '',
continueUrl: '',
continue: '',
};
SuccessMessage.propTypes = {
header: PropTypes.string,
message: PropTypes.string,
continueUrl: PropTypes.string,
continue: PropTypes.string,
};
AutoRedirect.defaultProps = {
redirectUrl: '',
};
AutoRedirect.propTypes = {
redirectUrl: PropTypes.string,
};
CoachingConsent.defaultProps = {
loaded: false,
};
CoachingConsent.propTypes = {
intl: intlShape.isRequired,
loaded: PropTypes.bool,
formValues: PropTypes.shape({
name: PropTypes.string,
phone_number: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
consent_form_seen: PropTypes.bool.isRequired,
}),
}).isRequired,
formErrors: PropTypes.shape({
coaching: PropTypes.object,
}).isRequired,
confirmationValues: PropTypes.shape({
coaching: PropTypes.object,
name: PropTypes.object,
phone_number: PropTypes.object,
}).isRequired,
fetchSettings: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
};
export default connect(coachingConsentPageSelector, {
fetchSettings,
saveSettings,
})(injectIntl(CoachingConsent));

View File

@@ -0,0 +1,61 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.coaching.consent.welcome.header': {
id: 'account.settings.coaching.consent.welcome.header',
defaultMessage: 'Lets get started.',
description: 'The welcome header for consent form.',
},
'account.settings.coaching.consent.welcome.subheader': {
id: 'account.settings.coaching.consent.welcome.subheader',
defaultMessage: "We're here for you from start to finish",
description: 'The welcome subheader for consent form.',
},
'account.settings.coaching.consent.description': {
id: 'account.settings.coaching.consent.description',
defaultMessage: "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
description: 'Text describing what Coaching is.',
},
'account.settings.coaching.consent.text-messaging.disclaimer': {
id: 'account.settings.coaching.consent.text-messaging.disclaimer',
defaultMessage: '* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.',
description: 'Text describing what Coaching is.',
},
'account.settings.coaching.consent.accept-coaching': {
id: 'account.settings.coaching.consent.accept-coaching',
defaultMessage: 'Sign up for coaching',
description: 'Text to confirm coaching enablement',
},
'account.settings.coaching.consent.decline-coaching': {
id: 'account.settings.coaching.consent.decline-coaching',
defaultMessage: 'I prefer not to be contacted with free coaching services',
description: 'Text to decline coaching enablement',
},
'account.settings.coaching.consent.label.name': {
id: 'account.settings.coaching.consent.label.name',
defaultMessage: 'Please confirm your name',
description: 'Label for name input',
},
'account.settings.coaching.consent.label.phone-number': {
id: 'account.settings.coaching.consent.label.phone-number',
defaultMessage: 'Enter your mobile number',
description: 'Label for mobile phone number input',
},
'account.settings.coaching.consent.success.header': {
id: 'account.settings.coaching.consent.success.header',
defaultMessage: 'Success!',
description: 'Heading announcing that submission succeeded',
},
'account.settings.coaching.consent.success.message': {
id: 'account.settings.coaching.consent.success.message',
defaultMessage: "You're signed up for coaching. You will receive a text message confirmation.",
description: 'Text announcing that you have signed up and will receive texts',
},
'account.settings.coaching.consent.success.continue': {
id: 'account.settings.coaching.consent.success.continue',
defaultMessage: 'Start my course',
description: 'Text that the user will be sent back to the courseware',
},
});
export default messages;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Input, Button, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import messages from './CoachingConsent.messages';
const ErrorMessage = props => (
<div className="alert-warning mb-2">{props.message}</div>
);
const CoachingForm = props => (
<div className="col-12 col-md-6 col-xl-5 mx-auto mt-4 p-5 shadow-lg">
<h2 className="h2">
{props.intl.formatMessage(messages['account.settings.coaching.consent.welcome.header'])}
</h2>
<p>{props.intl.formatMessage(messages['account.settings.coaching.consent.description'])}</p>
<div>
<form onSubmit={props.onSubmit}>
<div className="py-3">
<ErrorMessage message={props.formErrors.name} />
<label className="h6" htmlFor="fullName">{props.intl.formatMessage(messages['account.settings.coaching.consent.label.name'])}</label>
<Input
type="text"
name="full-name"
id="fullName"
defaultValue={props.formValues.name}
/>
</div>
<div className="py-3">
<ErrorMessage message={props.formErrors.phone_number} />
<label className="h6" htmlFor="phoneNumber">{props.intl.formatMessage(messages['account.settings.coaching.consent.label.phone-number'])}</label>
<Input
type="text"
name="full-name"
id="phoneNumber"
defaultValue={props.formValues.phone_number}
/>
</div>
<div className=" py-3">
<p className="small font-italic">
{props.intl.formatMessage(messages['account.settings.coaching.consent.text-messaging.disclaimer'])}
</p>
</div>
<ErrorMessage message={props.formErrors.coaching} />
<div className="d-flex flex-column align-items-center">
<Button className="w-100 btn-outline-primary" type="submit">
{props.intl.formatMessage(messages['account.settings.coaching.consent.accept-coaching'])}
</Button>
</div>
<div className="mt-3">
<Hyperlink
className="mt-3 text-dark btn-link small"
destination={props.redirectUrl}
onClick={props.declineCoaching}
>
{props.intl.formatMessage(messages['account.settings.coaching.consent.decline-coaching'])}
</Hyperlink>
</div>
</form>
</div>
</div>
);
CoachingForm.defaultProps = {
formErrors: {
coaching: '',
name: '',
phone_number: '',
},
};
CoachingForm.propTypes = {
intl: intlShape.isRequired,
onSubmit: PropTypes.func.isRequired,
declineCoaching: PropTypes.func.isRequired,
formValues: PropTypes.shape({
name: PropTypes.string,
phone_number: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
consent_form_seen: PropTypes.bool.isRequired,
}),
}).isRequired,
formErrors: PropTypes.shape({
coaching: PropTypes.string,
name: PropTypes.string,
phone_number: PropTypes.string,
}),
redirectUrl: PropTypes.string.isRequired,
};
ErrorMessage.defaultProps = {
message: '',
};
ErrorMessage.propTypes = {
message: PropTypes.string,
};
export default injectIntl(CoachingForm);

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 CoachingToggle = props => (
<>
<EditableField
name="phone_number"
type="text"
value={props.phone_number}
label={props.intl.formatMessage(messages['account.settings.field.phone_number'])}
emptyLabel={props.intl.formatMessage(messages['account.settings.field.phone_number.empty'])}
onChange={props.updateDraft}
onSubmit={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>
</>
);
CoachingToggle.defaultProps = {
phone_number: '',
error: '',
};
CoachingToggle.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(CoachingToggle));

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,49 @@
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) {
let data = null;
try {
({ data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`));
} catch (error) {
// Default values so the client doesn't fail if the user doesn't have an entry in the
// UserCoaching model yet, with the assumption that they'll be eligible for coaching
// when they hit this form.
data = {
coaching_consent: false,
user: userId,
eligible_for_coaching: true,
consent_form_seen: false,
};
}
return data;
}
/**
* patch all of the settings related to coaching.
* @param {Number} userId users are identified in the api by LMS id
* @param {Object} commitValues { coaching }
*/
export async function patchCoachingPreferences(userId, commitValues) {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`;
const { coaching } = commitValues;
coaching.user = userId;
await getAuthenticatedHttpClient()
.patch(requestUrl, coaching)
.catch((error) => {
const apiError = Object.create(error);
apiError.fieldErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
// 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

@@ -1,6 +1,4 @@
import { utils } from '../common';
const { AsyncActionType } = utils;
import { AsyncActionType } from './utils';
export const FETCH_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_SETTINGS');
export const SAVE_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_SETTINGS');

View File

@@ -9,9 +9,10 @@ import {
RESET_DRAFTS,
} from './actions';
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';
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,
@@ -26,6 +27,7 @@ export const defaultState = {
timeZones: [],
countryTimeZones: [],
previousSiteLanguage: null,
deleteAccount: deleteAccountReducer(),
siteLanguage: siteLanguageReducer(),
resetPassword: resetPasswordReducer(),
thirdPartyAuth: thirdPartyAuthReducer(),
@@ -149,8 +151,20 @@ const reducer = (state = defaultState, action) => {
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.
// 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:

View File

@@ -1,4 +1,8 @@
import { call, put, delay, takeEvery, select, all } from 'redux-saga/effects';
import { call, put, delay, takeEvery, all } from 'redux-saga/effects';
import { publish } from '@edx/frontend-platform';
import { getLocale, handleRtl, LOCALE_CHANGED } from '@edx/frontend-platform/i18n';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
// Actions
import {
@@ -16,30 +20,32 @@ import {
fetchTimeZones,
fetchTimeZonesSuccess,
} from './actions';
import { usernameSelector, userRolesSelector, siteLanguageSelector } from './selectors';
// Sub-modules
import { saga as resetPasswordSaga } from './reset-password';
import { saga as siteLanguageSaga, ApiService as SiteLanguageApiService } from './site-language';
import { saga as thirdPartyAuthSaga } from './third-party-auth';
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 * as ApiService from './service';
import { setLocale, handleRtl } from '@edx/frontend-i18n'; // eslint-disable-line
import { getSettings, patchSettings, getTimeZones } from './service';
export function* handleFetchSettings() {
try {
yield put(fetchSettingsBegin());
const username = yield select(usernameSelector);
const userRoles = yield select(userRolesSelector);
const { username, userId, roles: userRoles } = getAuthenticatedUser();
const {
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
} = yield call(
ApiService.getSettings,
getSettings,
username,
userRoles,
userId,
);
if (values.country) yield put(fetchTimeZones(values.country));
@@ -60,22 +66,25 @@ export function* handleSaveSettings(action) {
try {
yield put(saveSettingsBegin());
const username = yield select(usernameSelector);
const { username, userId } = getAuthenticatedUser();
const { commitValues, formId } = action.payload;
const commitData = { [formId]: commitValues };
let savedValues = null;
if (formId === 'siteLanguage') {
const previousSiteLanguage = yield select(siteLanguageSelector);
yield all([
call(SiteLanguageApiService.patchPreferences, username, { prefLang: commitValues }),
call(SiteLanguageApiService.postSetLang, commitValues),
]);
yield put(setLocale(commitValues));
yield put(savePreviousSiteLanguage(previousSiteLanguage.savedValue));
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(ApiService.patchSettings, username, commitData);
savedValues = yield call(patchSettings, username, commitData, userId);
}
yield put(saveSettingsSuccess(savedValues, commitData));
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
@@ -92,7 +101,7 @@ export function* handleSaveSettings(action) {
}
export function* handleFetchTimeZones(action) {
const response = yield call(ApiService.getTimeZones, action.payload.country);
const response = yield call(getTimeZones, action.payload.country);
yield put(fetchTimeZonesSuccess(response, action.payload.country));
}
@@ -102,6 +111,7 @@ export default function* saga() {
yield takeEvery(SAVE_SETTINGS.BASE, handleSaveSettings);
yield takeEvery(FETCH_TIME_ZONES.BASE, handleFetchTimeZones);
yield all([
deleteAccountSaga(),
siteLanguageSaga(),
resetPasswordSaga(),
thirdPartyAuthSaga(),

View File

@@ -1,24 +1,11 @@
import { createSelector, createStructuredSelector } from 'reselect';
import {
localeSelector,
getCountryList,
getLanguageList,
} from '@edx/frontend-i18n'; // eslint-disable-line
import { siteLanguageOptionsSelector, siteLanguageListSelector } from './site-language';
import { siteLanguageOptionsSelector, siteLanguageListSelector } from '../site-language';
export const storeName = 'accountSettings';
export const usernameSelector = state => state.authentication.username;
export const userRolesSelector = state => state.authentication.roles || [];
export const accountSettingsSelector = state => ({ ...state[storeName] });
const duplicateTpaProviderSelector = state => state.errors.duplicateTpaProvider;
const configurationSelector = state => state.configuration;
const editableFieldNameSelector = (state, props) => props.name;
const valuesSelector = createSelector(
@@ -54,6 +41,16 @@ const isEditingSelector = createSelector(
(name, accountSettings) => accountSettings.openFormId === name,
);
const confirmationValuesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.confirmationValues,
);
const errorSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.errors,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
@@ -76,10 +73,6 @@ export const staticFieldsSelector = createSelector(
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.
@@ -100,17 +93,6 @@ const formValuesSelector = createSelector(
},
);
const countryOptionsSelector = createSelector(
localeSelector,
locale => getCountryList(locale).map(({ code, name }) => ({ value: code, label: name })),
);
const languageProficiencyOptionsSelector = createSelector(
localeSelector,
locale => getLanguageList(locale).map(({ code, name }) => ({ value: code, label: name })),
);
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
.map(({ time_zone, description }) => ({ // eslint-disable-line camelcase
value: time_zone, label: description,
@@ -131,69 +113,43 @@ const activeAccountSelector = createSelector(
accountSettings => accountSettings.values.is_active,
);
/**
* This selector converts the site language code back to the server version so that it can match up
* with one of the options in the site language dropdown. The drafts version will already be the
* server version, but if it's from localeSelector, it will be our client (two character) version.
*/
export const siteLanguageSelector = createSelector(
previousSiteLanguageSelector,
draftsSelector,
localeSelector,
(previousValue, drafts, locale) => ({
(previousValue, drafts) => ({
previousValue,
draftOrSavedValue: (drafts.siteLanguage !== undefined ? drafts.siteLanguage : locale),
savedValue: locale,
draft: drafts.siteLanguage,
}),
);
export const betaLanguageBannerSelector = createSelector(
siteLanguageListSelector,
siteLanguageSelector,
(
siteLanguageList,
siteLanguage,
) => ({
siteLanguageList,
siteLanguage,
}),
);
export const betaLanguageBannerSelector = createStructuredSelector({
siteLanguageList: siteLanguageListSelector,
siteLanguage: siteLanguageSelector,
});
export const accountSettingsPageSelector = createSelector(
accountSettingsSelector,
siteLanguageOptionsSelector,
siteLanguageSelector,
countryOptionsSelector,
languageProficiencyOptionsSelector,
formValuesSelector,
profileDataManagerSelector,
staticFieldsSelector,
hiddenFieldsSelector,
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
duplicateTpaProviderSelector,
configurationSelector,
(
accountSettings,
siteLanguageOptions,
siteLanguage,
countryOptions,
languageProficiencyOptions,
formValues,
profileDataManager,
staticFields,
hiddenFields,
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
duplicateTpaProvider,
configuration,
) => ({
siteLanguageOptions,
siteLanguage,
countryOptions,
languageProficiencyOptions,
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
@@ -203,10 +159,32 @@ export const accountSettingsPageSelector = createSelector(
formValues,
profileDataManager,
staticFields,
hiddenFields,
duplicateTpaProvider,
tpaProviders: accountSettings.thirdPartyAuth.providers,
supportUrl: configuration.SUPPORT_URL,
logoutUrl: configuration.LOGOUT_URL,
}),
);
export const coachingConsentPageSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
activeAccountSelector,
saveStateSelector,
confirmationValuesSelector,
errorSelector,
(
accountSettings,
formValues,
activeAccount,
saveState,
confirmationValues,
errors,
) => ({
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
isActive: activeAccount,
formValues,
saveState,
confirmationValues,
formErrors: errors,
}),
);

View File

@@ -1,22 +1,12 @@
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 { applyConfiguration, handleRequestError, unpackFieldErrors } from '../common/serviceUtils';
import { configureService as configureDeleteAccountApiService } from './delete-account';
import { configureService as configureResetPasswordApiService } from './reset-password';
import { configureService as configureSiteLanguageApiService } from './site-language';
import { configureService as configureThirdPartyAuthApiService, getThirdPartyAuthProviders } from './third-party-auth';
let config = {
BASE_URL: null,
ACCOUNTS_API_BASE_URL: null,
PREFERENCES_API_BASE_URL: null,
ECOMMERCE_API_BASE_URL: null,
LMS_BASE_URL: null,
DELETE_ACCOUNT_URL: null,
PASSWORD_RESET_URL: null,
};
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' },
@@ -24,18 +14,6 @@ const SOCIAL_PLATFORMS = [
{ id: 'linkedin', key: 'social_link_linkedin' },
];
let apiClient = null;
export function configureService(newConfig, newApiClient) {
config = applyConfiguration(config, newConfig);
apiClient = newApiClient;
configureDeleteAccountApiService(config, apiClient);
configureResetPasswordApiService(config, apiClient);
configureSiteLanguageApiService(config, apiClient);
configureThirdPartyAuthApiService(config, apiClient);
}
function unpackAccountResponseData(data) {
const unpackedData = data;
@@ -90,7 +68,8 @@ function packAccountCommitData(commitData) {
}
export async function getAccount(username) {
const { data } = await apiClient.get(`${config.ACCOUNTS_API_BASE_URL}/${username}`);
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
return unpackAccountResponseData(data);
}
@@ -99,9 +78,9 @@ export async function patchAccount(username, commitValues) {
headers: { 'Content-Type': 'application/merge-patch+json' },
};
const { data } = await apiClient
const { data } = await getAuthenticatedHttpClient()
.patch(
`${config.ACCOUNTS_API_BASE_URL}/${username}`,
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`,
packAccountCommitData(commitValues),
requestConfig,
)
@@ -122,23 +101,25 @@ export async function patchAccount(username, commitValues) {
}
export async function getPreferences(username) {
const { data } = await apiClient.get(`${config.PREFERENCES_API_BASE_URL}/${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 = `${config.PREFERENCES_API_BASE_URL}/${username}`;
const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`;
// Ignore the success response, the API does not currently return any data.
await apiClient.patch(requestUrl, commitValues, requestConfig).catch(handleRequestError);
await getAuthenticatedHttpClient()
.patch(requestUrl, commitValues, requestConfig).catch(handleRequestError);
return commitValues;
}
export async function getTimeZones(forCountry) {
const { data } = await apiClient
.get(`${config.LMS_BASE_URL}/user_api/v1/preferences/time_zones/`, {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/user_api/v1/preferences/time_zones/`, {
params: { country_code: forCountry },
})
.catch(handleRequestError);
@@ -153,8 +134,8 @@ export async function getProfileDataManager(username, userRoles) {
const userRoleNames = userRoles.map(role => role.split(':')[0]);
if (userRoleNames.includes('enterprise_learner')) {
const url = `${config.LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/?username=${username}`;
const { data } = await apiClient.get(url).catch(handleRequestError);
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) {
@@ -171,15 +152,16 @@ export async function getProfileDataManager(username, userRoles) {
/**
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
* Currently encapsulates Account, Preferences, Coaching, and ThirdPartyAuth
*/
export async function getSettings(username, userRoles) {
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 {
@@ -188,20 +170,23 @@ export async function getSettings(username, userRoles) {
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, and ThirdPartyAuth
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
*/
export async function patchSettings(username, commitValues) {
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)) {
@@ -210,6 +195,9 @@ export async function patchSettings(username, commitValues) {
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
@@ -217,4 +205,3 @@ export async function patchSettings(username, commitValues) {
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

@@ -0,0 +1,38 @@
import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase';
export function modifyObjectKeys(object, modify) {
// If the passed in object is not an object, return it.
if (
object === undefined ||
object === null ||
(typeof object !== 'object' && !Array.isArray(object))
) {
return object;
}
if (Array.isArray(object)) {
return object.map(value => modifyObjectKeys(value, modify));
}
// Otherwise, process all its keys.
const result = {};
Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify);
});
return result;
}
export function camelCaseObject(object) {
return modifyObjectKeys(object, camelCase);
}
export function snakeCaseObject(object) {
return modifyObjectKeys(object, snakeCase);
}
export function convertKeyNames(object, nameMap) {
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
return modifyObjectKeys(object, transformer);
}

View File

@@ -1,12 +1,9 @@
import {
AsyncActionType,
modifyObjectKeys,
camelCaseObject,
snakeCaseObject,
convertKeyNames,
keepKeys,
getModuleState,
} from './utils';
} from './dataUtils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
@@ -91,77 +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');
});
});
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,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

@@ -1,50 +1,32 @@
import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase';
export function modifyObjectKeys(object, modify) {
// If the passed in object is not an object, return it.
if (
object === undefined ||
object === null ||
(typeof object !== 'object' && !Array.isArray(object))
) {
return object;
/**
* 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;
}
if (Array.isArray(object)) {
return object.map(value => modifyObjectKeys(value, modify));
get BASE() {
return `${this.topic}__${this.name}`;
}
// Otherwise, process all its keys.
const result = {};
Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify);
});
return result;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
export function camelCaseObject(object) {
return modifyObjectKeys(object, camelCase);
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
export function snakeCaseObject(object) {
return modifyObjectKeys(object, snakeCase);
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
export function convertKeyNames(object, nameMap) {
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
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;
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
}
/**
@@ -78,36 +60,3 @@ export function getModuleState(state, originalPath) {
}
return getModuleState(state[key], path);
}
/**
* 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

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

@@ -1,16 +1,16 @@
import { put } from 'redux-saga/effects';
import { push } from 'connected-react-router';
import { logAPIErrorResponse } from '@edx/frontend-logging';
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 }));
}
logAPIErrorResponse(error);
logError(error);
if (failureAction !== null) {
yield put(failureAction(error.message));
}
if (failureRedirectPath !== null) {
yield put(push(failureRedirectPath));
history.push(failureRedirectPath);
}
}

View File

@@ -1,14 +1,3 @@
import pick from 'lodash.pick';
export function applyConfiguration(expected, actual) {
Object.keys(expected).forEach((key) => {
if (actual[key] === undefined) {
throw new Error(`Service configuration error: ${key} is required.`);
}
});
return pick(actual, Object.keys(expected));
}
/**
* Turns field errors of the form:
*

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-i18n';
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';
@@ -9,10 +9,10 @@ import { Hyperlink } from '@edx/paragon';
import messages from './messages';
// Components
import { Alert } from '../../common';
import Alert from '../Alert';
const BeforeProceedingBanner = (props) => {
const { instructionMessageId, intl, supportUrl } = props;
const { instructionMessageId, intl, supportArticleUrl } = props;
return (
<Alert
@@ -25,7 +25,7 @@ const BeforeProceedingBanner = (props) => {
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={supportUrl}>
<Hyperlink destination={supportArticleUrl}>
{intl.formatMessage(messages[instructionMessageId])}
</Hyperlink>
),
@@ -38,7 +38,7 @@ const BeforeProceedingBanner = (props) => {
BeforeProceedingBanner.propTypes = {
instructionMessageId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
supportUrl: PropTypes.string.isRequired,
supportArticleUrl: PropTypes.string.isRequired,
};
export default injectIntl(BeforeProceedingBanner);

View File

@@ -2,12 +2,12 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Button, Input, Modal, ValidationFormGroup } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
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 '../../common';
import Alert from '../Alert';
import PrintingInstructions from './PrintingInstructions';
export class ConfirmationModal extends Component {
@@ -51,7 +51,7 @@ export class ConfirmationModal extends Component {
render() {
const {
open,
status,
errorType,
intl,
onCancel,
@@ -59,6 +59,7 @@ export class ConfirmationModal extends Component {
onSubmit,
password,
} = this.props;
const open = ['confirming', 'pending', 'failed'].includes(status);
const passwordFieldId = 'passwordFieldId';
const invalidMessage = messages[this.getShortErrorMessageId(errorType)];
return (
@@ -112,7 +113,7 @@ export class ConfirmationModal extends Component {
}
ConfirmationModal.propTypes = {
open: PropTypes.bool,
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
errorType: PropTypes.oneOf(['empty-password', 'server']),
intl: intlShape.isRequired,
onCancel: PropTypes.func.isRequired,
@@ -122,7 +123,7 @@ ConfirmationModal.propTypes = {
};
ConfirmationModal.defaultProps = {
open: false,
status: null,
errorType: null,
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-i18n';
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;
@@ -18,10 +18,9 @@ describe('ConfirmationModal', () => {
onCancel: jest.fn(),
onChange: jest.fn(),
onSubmit: jest.fn(),
open: false,
status: null,
errorType: null,
password: 'fluffy bunnies',
logoutUrl: 'http://localhost/logout',
};
});
@@ -44,8 +43,7 @@ describe('ConfirmationModal', () => {
<IntlProvider locale="en">
<IntlConfirmationModal
{...props}
// This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
open
status="pending" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
/>
</IntlProvider>
))
@@ -60,8 +58,7 @@ describe('ConfirmationModal', () => {
<IntlConfirmationModal
{...props}
errorType="empty-password"
// This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
open
status="pending" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
/>
</IntlProvider>
))

View File

@@ -1,8 +1,19 @@
import React, { useState, useCallback } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
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';
@@ -11,125 +22,128 @@ import ConnectedConfirmationModal from './ConfirmationModal';
import PrintingInstructions from './PrintingInstructions';
import ConnectedSuccessModal from './SuccessModal';
import BeforeProceedingBanner from './BeforeProceedingBanner';
import { postDeleteAccount } from './data/service';
import useAction from '../../common/hooks';
function DeleteAccount(props) {
const {
logoutUrl, hasLinkedTPA, isVerifiedAccount, intl,
} = props;
export class DeleteAccount extends React.Component {
state = {
password: '',
};
const [password, setPassword] = useState('');
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const [validationError, setValidationError] = useState(null);
const [
deleteAccountState,
performDeleteAccount,
resetDeleteAccount,
] = useAction(postDeleteAccount);
const canDelete = isVerifiedAccount && !hasLinkedTPA;
const successDialogOpen = deleteAccountState.loaded;
const error = deleteAccountState.error !== null ? 'server' : validationError;
// Event handlers
const handleDeleteAccountClick = useCallback(() => {
if (canDelete) {
setConfirmationDialogOpen(true);
}
});
const handleSubmit = useCallback(() => {
if (password === '') {
setValidationError('empty-password');
handleSubmit = () => {
if (this.state.password === '') {
this.props.deleteAccountFailure('empty-password');
} else {
performDeleteAccount(password);
this.props.deleteAccount(this.state.password);
}
});
};
const handleCancel = useCallback(() => {
setPassword('');
setConfirmationDialogOpen(false);
resetDeleteAccount();
});
handleCancel = () => {
this.setState({ password: '' });
this.props.deleteAccountCancel();
};
const handlePasswordChange = useCallback((e) => {
setPassword(e.target.value.trim());
setValidationError(null);
});
handlePasswordChange = (e) => {
this.setState({ password: e.target.value.trim() });
this.props.deleteAccountReset();
};
const handleFinalClose = useCallback(() => {
global.location = logoutUrl;
});
handleFinalClose = () => {
global.location = getConfig().LOGOUT_URL;
};
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={handleDeleteAccountClick}
disabled={!canDelete}
>
{intl.formatMessage(messages['account.settings.delete.account.button'])}
</Button>
</p>
render() {
const {
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
} = this.props;
const canDelete = isVerifiedAccount && !hasLinkedTPA;
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.activate"
supportUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
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}
/>
)}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportUrl="https://support.edx.org/hc/en-us/articles/207206067"
/>
) : null}
<ConnectedConfirmationModal
open={confirmationDialogOpen}
errorType={error}
onSubmit={handleSubmit}
onCancel={handleCancel}
onChange={handlePasswordChange}
password={password}
/>
<ConnectedSuccessModal open={successDialogOpen} onClose={handleFinalClose} />
</div>
);
<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,
logoutUrl: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
DeleteAccount.defaultProps = {
hasLinkedTPA: false,
isVerifiedAccount: true,
status: null,
errorType: null,
};
export default injectIntl(DeleteAccount);
// 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

@@ -1,21 +1,29 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider } from '@edx/frontend-i18n';
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
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,
logoutUrl: 'http://localhost/logout',
};
});
@@ -23,7 +31,7 @@ describe('DeleteAccount', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<DeleteAccount
<IntlDeleteAccount
{...props}
/>
</IntlProvider>
@@ -36,7 +44,7 @@ describe('DeleteAccount', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<DeleteAccount
<IntlDeleteAccount
{...props}
isVerifiedAccount={false}
/>
@@ -50,7 +58,7 @@ describe('DeleteAccount', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<DeleteAccount
<IntlDeleteAccount
{...props}
hasLinkedTPA
/>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import messages from './messages';

View File

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

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-i18n';
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;
@@ -16,15 +16,30 @@ describe('SuccessModal', () => {
beforeEach(() => {
props = {
onClose: jest.fn(),
open: false,
status: null,
};
});
it('should match default closed success modal snapshot', () => {
const tree = renderer.create((
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', () => {
@@ -33,8 +48,7 @@ describe('SuccessModal', () => {
<IntlProvider locale="en">
<IntlSuccessModal
{...props}
// This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
open
status="deleted" // This will cause 'modal-backdrop' and 'show' to appear on the modal as CSS classes.
/>
</IntlProvider>
))

View File

@@ -8,11 +8,11 @@ exports[`ConfirmationModal should match default closed confirmation modal snapsh
/>
<div
className="modal js-close-modal-on-click fade"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id3"
aria-labelledby="id2"
aria-modal={true}
className=""
role="dialog"
@@ -26,7 +26,7 @@ exports[`ConfirmationModal should match default closed confirmation modal snapsh
>
<h2
className="modal-title"
id="id3"
id="id2"
>
Are you sure?
</h2>
@@ -120,6 +120,7 @@ exports[`ConfirmationModal should match default closed confirmation modal snapsh
</button>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton1"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@@ -142,11 +143,11 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
/>
<div
className="modal js-close-modal-on-click show d-block"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id5"
aria-labelledby="id6"
aria-modal={true}
className="modal-dialog"
role="dialog"
@@ -160,7 +161,7 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
>
<h2
className="modal-title"
id="id5"
id="id6"
>
Are you sure?
</h2>
@@ -287,6 +288,7 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
</button>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton5"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@@ -309,7 +311,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
/>
<div
className="modal js-close-modal-on-click show d-block"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
@@ -421,6 +423,7 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
</button>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton3"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}

View File

@@ -8,11 +8,11 @@ exports[`SuccessModal should match default closed success modal snapshot 1`] = `
/>
<div
className="modal js-close-modal-on-click fade"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id3"
aria-labelledby="id2"
aria-modal={true}
className=""
role="dialog"
@@ -26,7 +26,7 @@ exports[`SuccessModal should match default closed success modal snapshot 1`] = `
>
<h2
className="modal-title"
id="id3"
id="id2"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
@@ -47,6 +47,7 @@ exports[`SuccessModal should match default closed success modal snapshot 1`] = `
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton1"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@@ -61,21 +62,21 @@ exports[`SuccessModal should match default closed success modal snapshot 1`] = `
</div>
`;
exports[`SuccessModal should match open success modal snapshot 1`] = `
exports[`SuccessModal should match default closed success modal snapshot 2`] = `
<div>
<div
className="modal-backdrop show"
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click show d-block"
onClick={[Function]}
className="modal js-close-modal-on-click fade"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id4"
aria-modal={true}
className="modal-dialog"
className=""
role="dialog"
tabIndex="-1"
>
@@ -108,6 +109,193 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
>
<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]}

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

@@ -1,24 +1,16 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { applyConfiguration, handleRequestError } from '../../../common/serviceUtils';
let config = {
DELETE_ACCOUNT_URL: null,
};
let apiClient = null;
export function configureService(newConfig, newApiClient) {
config = applyConfiguration(config, newConfig);
apiClient = newApiClient;
}
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 apiClient
const { data } = await getAuthenticatedHttpClient()
.post(
config.DELETE_ACCOUNT_URL,
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/deactivate_logout/`,
formurlencoded({ password }),
{
headers: {

View File

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

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-i18n';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.delete.account.header': {

View File

@@ -1,13 +1,5 @@
import ConnectedAccountSettingsPage from './AccountSettingsPage';
import reducer from './reducers';
import saga from './sagas';
import { configureService } from './service';
import { storeName } from './selectors';
export {
configureService,
ConnectedAccountSettingsPage,
reducer,
saga,
storeName,
};
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

@@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-i18n';
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 '../../common';
import Alert from '../Alert';
const ConfirmationAlert = (props) => {
const { email } = props;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { StatefulButton } from '@edx/paragon';
import { resetPassword } from './data/actions';

View File

@@ -1,6 +1,4 @@
import { utils } from '../../../common';
const { AsyncActionType } = utils;
import { AsyncActionType } from '../../data/utils';
export const RESET_PASSWORD = new AsyncActionType('ACCOUNT_SETTINGS', 'RESET_PASSWORD');

View File

@@ -1,21 +1,13 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { applyConfiguration, handleRequestError } from '../../../common/serviceUtils';
let config = {
PASSWORD_RESET_URL: null,
};
let apiClient = null;
export function configureService(newConfig, newApiClient) {
config = applyConfiguration(config, newConfig);
apiClient = newApiClient;
}
import { handleRequestError } from '../../data/utils';
// eslint-disable-next-line import/prefer-default-export
export async function postResetPassword(email) {
const { data } = await apiClient
const { data } = await getAuthenticatedHttpClient()
.post(
config.PASSWORD_RESET_URL,
`${getConfig().LMS_BASE_URL}/password_reset/`,
formurlencoded({ email }),
{
headers: {

View File

@@ -2,4 +2,3 @@ export { default } from './ResetPassword';
export { default as reducer } from './data/reducers';
export { RESET_PASSWORD } from './data/actions';
export { default as saga } from './data/sagas';
export { configureService } from './data/service';

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-i18n';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.editable.field.password.reset.button': {

View File

@@ -1,4 +1,4 @@
import { AsyncActionType } from '../../common/utils';
import { AsyncActionType } from '../data/utils';
export const FETCH_SITE_LANGUAGES = new AsyncActionType('SITE_LANGUAGE', 'FETCH_SITE_LANGUAGES');

View File

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

View File

@@ -7,13 +7,13 @@ import {
FETCH_SITE_LANGUAGES,
} from './actions';
import { ApiService } from './service';
import handleFailure from '../../common/sagaUtils';
import { getSiteLanguageList } from './service';
import { handleFailure } from '../data/utils';
function* handleFetchSiteLanguages() {
try {
yield put(fetchSiteLanguagesBegin());
const siteLanguageList = yield call(ApiService.getSiteLanguageList);
const siteLanguageList = yield call(getSiteLanguageList);
yield put(fetchSiteLanguagesSuccess(siteLanguageList));
} catch (e) {
yield call(handleFailure, e, fetchSiteLanguagesFailure);

View File

@@ -1,5 +1,5 @@
import { createSelector } from 'reselect';
import { getModuleState } from '../../common/utils';
import { getModuleState } from '../data/utils';
export const storePath = ['accountSettings', 'siteLanguage'];

View File

@@ -1,48 +1,32 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import siteLanguageList from './constants';
import { snakeCaseObject, convertKeyNames } from '../../common/utils';
import { applyConfiguration } from '../../common/serviceUtils';
import { snakeCaseObject, convertKeyNames } from '../data/utils';
let config = {
BASE_URL: null,
PREFERENCES_API_BASE_URL: null,
LMS_BASE_URL: null,
};
let apiClient = null;
export function configureService(newConfig, newApiClient) {
config = applyConfiguration(config, newConfig);
apiClient = newApiClient;
}
async function getSiteLanguageList() {
export async function getSiteLanguageList() {
return siteLanguageList;
}
async function patchPreferences(username, params) {
export async function patchPreferences(username, params) {
let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, {
pref_lang: 'pref-lang',
});
await apiClient.patch(`${config.PREFERENCES_API_BASE_URL}/${username}`, processedParams, {
headers: { 'Content-Type': 'application/merge-patch+json' },
});
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.
}
async function postSetLang(code) {
export async function postSetLang(code) {
const formData = new FormData();
formData.append('language', code);
await apiClient.post(`${config.LMS_BASE_URL}/i18n/setlang/`, formData, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
await getAuthenticatedHttpClient()
.post(`${getConfig().LMS_BASE_URL}/i18n/setlang/`, formData, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
}
export const ApiService = {
getSiteLanguageList,
patchPreferences,
postSetLang,
};

View File

@@ -1,10 +1,10 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-i18n';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, StatefulButton } from '@edx/paragon';
import { Alert } from '../../common';
import Alert from '../Alert';
import { disconnectAuth } from './data/actions';
class ThirdPartyAuth extends Component {

View File

@@ -1,6 +1,4 @@
import { utils } from '../../../common';
const { AsyncActionType } = utils;
import { AsyncActionType } from '../../data/utils';
export const DISCONNECT_AUTH = new AsyncActionType('ACCOUNT_SETTINGS', 'DISCONNECT_AUTH');

View File

@@ -1,5 +1,5 @@
import { call, put, takeEvery } from 'redux-saga/effects';
import { logAPIErrorResponse } from '@edx/frontend-logging';
import { logError } from '@edx/frontend-platform/logging';
import {
disconnectAuthReset,
@@ -23,7 +23,7 @@ function* handleDisconnectAuth(action) {
const thirdPartyAuthProviders = yield call(getThirdPartyAuthProviders);
yield put(disconnectAuthSuccess(providerId, thirdPartyAuthProviders));
} catch (e) {
logAPIErrorResponse(e);
logError(e);
yield put(disconnectAuthFailure(providerId));
}
}

View File

@@ -1,29 +1,23 @@
import { applyConfiguration, handleRequestError } from '../../../common/serviceUtils';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
let config = {
LMS_BASE_URL: null,
};
let apiClient = null;
export function configureService(newConfig, newApiClient) {
config = applyConfiguration(config, newConfig);
apiClient = newApiClient;
}
import { handleRequestError } from '../../data/utils';
export async function getThirdPartyAuthProviders() {
const { data } = await apiClient
.get(`${config.LMS_BASE_URL}/api/third_party_auth/v0/providers/user_status`)
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: `${config.LMS_BASE_URL}${connectUrl}`,
disconnectUrl: `${config.LMS_BASE_URL}${disconnectUrl}`,
connectUrl: `${getConfig().LMS_BASE_URL}${connectUrl}`,
disconnectUrl: `${getConfig().LMS_BASE_URL}${disconnectUrl}`,
}));
}
export async function postDisconnectAuth(url) {
const { data } = await apiClient.post(url).catch(handleRequestError);
const { data } = await getAuthenticatedHttpClient()
.post(url)
.catch(handleRequestError);
return data;
}

View File

@@ -1,5 +1,5 @@
export { default } from './ThirdPartyAuth';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { configureService, getThirdPartyAuthProviders } from './data/service';
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,3 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`keepKeys 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

@@ -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,41 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { logAPIErrorResponse } from '@edx/frontend-logging';
import ErrorPage from './ErrorPage';
/*
Error boundary component used to log caught errors and display the error page.
*/
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, info) {
logAPIErrorResponse(`${error} ${info}`);
}
render() {
if (this.state.hasError) {
return <ErrorPage />;
}
return this.props.children;
}
}
ErrorBoundary.propTypes = {
children: PropTypes.node,
};
ErrorBoundary.defaultProps = {
children: null,
};

View File

@@ -1,42 +0,0 @@
import React, { Component } from 'react';
import { FormattedMessage } from '@edx/frontend-i18n';
import { Button } from '@edx/paragon';
export default class ErrorPage extends Component {
reload() {
window.location.reload();
}
render() {
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="unexpected.error.message.text"
defaultMessage="An unexpected error occurred. Please click the button below to return to refresh the page."
description="error message when an unexpected error occurs"
/>
</p>
</div>
</div>
<div className="row">
<div className="col">
<Button
buttonType="primary"
onClick={this.reload}
label={
<FormattedMessage
id="unexpected.error.button.text"
defaultMessage="Try Again"
description="text for button that tries to reload the app by refreshing the page"
/>
}
/>
</div>
</div>
</div>
);
}
}

View File

@@ -1,42 +0,0 @@
import { useState } from 'react';
const useAction = (action) => {
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const performAction = async (body = null) => {
try {
setLoading(true);
setLoaded(false);
setData(null);
setError(null);
const result = await action(body);
setData(result);
setLoaded(true);
} catch (e) {
if (e.response.data) {
setError(e.response.data);
} else {
throw e;
}
setLoaded(false);
} finally {
setLoading(false);
}
};
const resetAction = () => {
setLoading(false);
setLoaded(false);
setData(null);
setError(null);
};
return [{
loaded, loading, data, error,
}, performAction, resetAction];
};
export default useAction;

View File

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

View File

@@ -1,266 +0,0 @@
import React, { Component } from 'react';
import { connect, Provider } from 'react-redux';
import PropTypes from 'prop-types';
import { Route, Switch } from 'react-router-dom';
import { ConnectedRouter } from 'connected-react-router';
import { sendTrackEvent } from '@edx/frontend-analytics';
import { IntlProvider, injectIntl, intlShape, getMessages } from '@edx/frontend-i18n';
import SiteHeader from '@edx/frontend-component-site-header';
import SiteFooter from '@edx/frontend-component-footer';
import {
faFacebookSquare,
faTwitterSquare,
faYoutubeSquare,
faLinkedin,
faRedditSquare,
} from '@fortawesome/free-brands-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { ErrorBoundary, fetchUserAccount } from '../common';
import { ConnectedAccountSettingsPage } from '../account-settings';
import FooterLogo from '../assets/edx-footer.png';
import HeaderLogo from '../assets/logo.svg';
import NotFoundPage from './NotFoundPage';
import messages from './App.messages';
function PageContent({
configuration,
username,
avatar,
intl,
}) {
const mainMenu = [
{
type: 'item',
href: `${configuration.LMS_BASE_URL}/dashboard`,
content: intl.formatMessage(messages['siteheader.links.courses']),
},
{
type: 'item',
href: `${configuration.LMS_BASE_URL}/dashboard/programs`,
content: intl.formatMessage(messages['siteheader.links.programs']),
},
{
type: 'item',
href: `${configuration.MARKETING_SITE_BASE_URL}/course`,
content: intl.formatMessage(messages['siteheader.links.content.search']),
onClick: () => {
sendTrackEvent(
'edx.bi.dashboard.find_courses_button.clicked',
{ category: 'account', label: 'header' },
);
},
},
];
const userMenu = [
{
type: 'item',
href: `${configuration.LMS_BASE_URL}`,
content: intl.formatMessage(messages['siteheader.user.menu.dashboard']),
},
{
type: 'item',
href: `${configuration.LMS_BASE_URL}/u/${username}`,
content: intl.formatMessage(messages['siteheader.user.menu.profile']),
},
{
type: 'item',
href: `${configuration.LMS_BASE_URL}/account/settings`,
content: intl.formatMessage(messages['siteheader.user.menu.account.settings']),
},
{
type: 'item',
href: configuration.ORDER_HISTORY_URL,
content: intl.formatMessage(messages['siteheader.user.menu.order.history']),
},
{
type: 'item',
href: configuration.LOGOUT_URL,
content: intl.formatMessage(messages['siteheader.user.menu.logout']),
},
];
const loggedOutItems = [
{
type: 'item',
href: `${configuration.LMS_BASE_URL}/login`,
content: intl.formatMessage(messages['siteheader.user.menu.login']),
},
{
type: 'item',
href: `${configuration.LMS_BASE_URL}/register`,
content: intl.formatMessage(messages['siteheader.user.menu.register']),
},
];
const socialLinks = [
{
title: 'Facebook',
url: configuration.FACEBOOK_URL,
icon: <FontAwesomeIcon icon={faFacebookSquare} className="social-icon" size="2x" />,
screenReaderText: 'Like edX on Facebook',
},
{
title: 'Twitter',
url: configuration.TWITTER_URL,
icon: <FontAwesomeIcon icon={faTwitterSquare} className="social-icon" size="2x" />,
screenReaderText: 'Follow edX on Twitter',
},
{
title: 'Youtube',
url: configuration.YOU_TUBE_URL,
icon: <FontAwesomeIcon icon={faYoutubeSquare} className="social-icon" size="2x" />,
screenReaderText: 'Subscribe to the edX YouTube channel',
},
{
title: 'LinkedIn',
url: configuration.LINKED_IN_URL,
icon: <FontAwesomeIcon icon={faLinkedin} className="social-icon" size="2x" />,
screenReaderText: 'Follow edX on LinkedIn',
},
{
title: 'Reddit',
url: configuration.REDDIT_URL,
icon: <FontAwesomeIcon icon={faRedditSquare} className="social-icon" size="2x" />,
screenReaderText: 'Subscribe to the edX subreddit',
},
];
return (
<div id="app">
<SiteHeader
logo={HeaderLogo}
loggedIn
username={username}
avatar={avatar}
logoAltText={configuration.SITE_NAME}
logoDestination={`${configuration.LMS_BASE_URL}/dashboard`}
mainMenu={mainMenu}
userMenu={userMenu}
loggedOutItems={loggedOutItems}
/>
<main>
<Switch>
<Route exact path="/" component={ConnectedAccountSettingsPage} />
<Route path="/notfound" component={NotFoundPage} />
<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}
appleAppStoreUrl={configuration.APPLE_APP_STORE_URL}
googlePlayUrl={configuration.GOOGLE_PLAY_URL}
socialLinks={socialLinks}
enterpriseMarketingLink={{
url: configuration.ENTERPRISE_MARKETING_URL,
queryParams: {
utm_source: configuration.ENTERPRISE_MARKETING_UTM_SOURCE,
utm_campaign: configuration.ENTERPRISE_MARKETING_UTM_CAMPAIGN,
utm_medium: configuration.ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM,
},
}}
handleAllTrackEvents={sendTrackEvent}
/>
</div>
);
}
const IntlPageContent = injectIntl(PageContent);
class App extends Component {
componentDidMount() {
const { username } = this.props;
this.props.fetchUserAccount(username);
}
render() {
return (
<ErrorBoundary>
<IntlProvider locale={this.props.locale} messages={getMessages()}>
<Provider store={this.props.store}>
<ConnectedRouter history={this.props.history}>
<IntlPageContent
configuration={this.props.configuration}
username={this.props.username}
avatar={this.props.avatar}
/>
</ConnectedRouter>
</Provider>
</IntlProvider>
</ErrorBoundary>
);
}
}
const configurationPropTypes = {
SITE_NAME: PropTypes.string.isRequired,
LMS_BASE_URL: PropTypes.string.isRequired,
LOGOUT_URL: 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,
REDDIT_URL: PropTypes.string.isRequired,
APPLE_APP_STORE_URL: PropTypes.string.isRequired,
GOOGLE_PLAY_URL: PropTypes.string.isRequired,
ORDER_HISTORY_URL: PropTypes.string.isRequired,
ENTERPRISE_MARKETING_URL: PropTypes.string.isRequired,
ENTERPRISE_MARKETING_UTM_SOURCE: PropTypes.string.isRequired,
ENTERPRISE_MARKETING_UTM_CAMPAIGN: PropTypes.string.isRequired,
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: PropTypes.string.isRequired,
};
PageContent.propTypes = {
username: PropTypes.string.isRequired,
avatar: PropTypes.string,
configuration: PropTypes.shape(configurationPropTypes).isRequired,
intl: intlShape.isRequired,
};
PageContent.defaultProps = {
avatar: null,
};
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
locale: PropTypes.string.isRequired,
configuration: PropTypes.shape(configurationPropTypes).isRequired,
};
App.defaultProps = {
avatar: null,
};
const mapStateToProps = state => ({
username: state.authentication.username,
configuration: state.configuration,
locale: state.i18n.locale,
avatar: state.userAccount.profileImage.hasImage
? state.userAccount.profileImage.imageUrlMedium
: null,
});
export default connect(
mapStateToProps,
{
fetchUserAccount,
},
)(App);

View File

@@ -1,61 +0,0 @@
import { defineMessages } from '@edx/frontend-i18n';
const messages = defineMessages({
'siteheader.links.courses': {
id: 'siteheader.links.courses',
defaultMessage: 'Courses',
description: 'Link to the learner course dashboard',
},
'siteheader.links.programs': {
id: 'siteheader.links.programs',
defaultMessage: 'Programs',
description: 'Link to the learner program dashboard',
},
'siteheader.links.content.search': {
id: 'siteheader.links.content.search',
defaultMessage: 'Discover New',
description: 'Link to the content search 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.order.history': {
id: 'siteheader.user.menu.order.history',
defaultMessage: 'Order History',
description: 'Link to order history',
},
'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

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

View File

@@ -1,5 +1,5 @@
import { all } from 'redux-saga/effects';
import { saga as accountSettingsSaga } from './account-settings';
import { saga as accountSettingsSaga } from '../account-settings';
export default function* rootSaga() {
yield all([accountSettingsSaga()]);

View File

@@ -1,47 +0,0 @@
export const configuration = {
BASE_URL: process.env.BASE_URL,
LMS_BASE_URL: process.env.LMS_BASE_URL,
ECOMMERCE_BASE_URL: process.env.ECOMMERCE_BASE_URL,
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH,
REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT,
SEGMENT_KEY: process.env.SEGMENT_KEY,
ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME,
USER_INFO_COOKIE_NAME: process.env.USER_INFO_COOKIE_NAME,
CSRF_COOKIE_NAME: process.env.CSRF_COOKIE_NAME,
LANGUAGE_PREFERENCE_COOKIE_NAME: process.env.LANGUAGE_PREFERENCE_COOKIE_NAME,
SITE_NAME: process.env.SITE_NAME,
MARKETING_SITE_BASE_URL: process.env.MARKETING_SITE_BASE_URL,
SUPPORT_URL: process.env.SUPPORT_URL,
CONTACT_URL: process.env.CONTACT_URL,
OPEN_SOURCE_URL: process.env.OPEN_SOURCE_URL,
TERMS_OF_SERVICE_URL: process.env.TERMS_OF_SERVICE_URL,
PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL,
FACEBOOK_URL: process.env.FACEBOOK_URL,
TWITTER_URL: process.env.TWITTER_URL,
YOU_TUBE_URL: process.env.YOU_TUBE_URL,
LINKED_IN_URL: process.env.LINKED_IN_URL,
REDDIT_URL: process.env.REDDIT_URL,
APPLE_APP_STORE_URL: process.env.APPLE_APP_STORE_URL,
GOOGLE_PLAY_URL: process.env.GOOGLE_PLAY_URL,
ACCOUNT_SETTINGS_URL: `${process.env.LMS_BASE_URL}/account/settings`,
DATA_API_BASE_URL: process.env.DATA_API_BASE_URL,
SECURE_COOKIES: process.env.NODE_ENV !== 'development',
ENVIRONMENT: process.env.NODE_ENV,
ACCOUNTS_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/user/v1/accounts`,
PREFERENCES_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/user/v1/preferences`,
CERTIFICATES_API_BASE_URL: `${process.env.LMS_BASE_URL}/api/certificates/v0/certificates`,
VIEW_MY_RECORDS_URL: `${process.env.CREDENTIALS_BASE_URL}/records`,
ECOMMERCE_API_BASE_URL: `${process.env.ECOMMERCE_BASE_URL}/api/v2`,
ORDER_HISTORY_URL: process.env.ORDER_HISTORY_URL,
DELETE_ACCOUNT_URL: `${process.env.LMS_BASE_URL}/api/user/v1/accounts/deactivate_logout/`,
PASSWORD_RESET_URL: `${process.env.LMS_BASE_URL}/password_reset/`,
ENTERPRISE_MARKETING_URL: process.env.ENTERPRISE_MARKETING_URL,
ENTERPRISE_MARKETING_UTM_SOURCE: process.env.ENTERPRISE_MARKETING_UTM_SOURCE,
ENTERPRISE_MARKETING_UTM_CAMPAIGN: process.env.ENTERPRISE_MARKETING_UTM_CAMPAIGN,
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM: process.env.ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM,
};
export const features = {};

View File

@@ -76,6 +76,22 @@
"account.settings.editable.field.action.edit": "Edit",
"account.settings.static.field.empty": "No value set. Contact your {enterprise} administrator to make changes.",
"account.settings.static.field.empty.no.admin": "No value set.",
"account.settings.coaching.consent.welcome.header": "Lets get started.",
"account.settings.coaching.consent.welcome.subheader": "We're here for you from start to finish",
"account.settings.coaching.consent.description": "MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*",
"account.settings.coaching.consent.text-messaging.disclaimer": "* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.",
"account.settings.coaching.consent.accept-coaching": "Sign up for coaching",
"account.settings.coaching.consent.decline-coaching": "I prefer not to be contacted with free coaching services",
"account.settings.coaching.consent.label.name": "Please confirm your name",
"account.settings.coaching.consent.label.phone-number": "Enter your mobile number",
"account.settings.coaching.consent.success.header": "Success!",
"account.settings.coaching.consent.success.message": "You're signed up for coaching. You will receive a text message confirmation.",
"account.settings.coaching.consent.success.continue": "Start my course",
"account.settings.field.phone_number": "Phone Number",
"account.settings.field.phone_number.empty": "Add a phone number",
"account.settings.field.coaching_consent": "Coaching consent",
"account.settings.field.coaching_consent.tooltip": "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.",
"account.settings.field.coaching_consent.error": "A valid US phone number is required to opt into coaching",
"account.settings.delete.account.before.proceeding": "Before proceeding, please {actionLink}.",
"account.settings.delete.account.header": "Delete My Account",
"account.settings.delete.account.subheader": "We're sorry to see you go!",
@@ -100,6 +116,7 @@
"account.settings.delete.account.modal.after.text": "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.",
"account.settings.delete.account.modal.after.button": "Close",
"account.settings.delete.account.text.3": "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}.",
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"account.settings.editable.field.password.reset.button.confirmation.support.link": "technical support",
"account.settings.editable.field.password.reset.button.confirmation": "We've sent a message to {email}. Click the link in the message to reset your password. Didn't receive the message? Contact {technicalSupportLink}.",
"account.settings.editable.field.password.reset.button": "Reset Password",
@@ -108,19 +125,5 @@
"account.settings.sso.account.connected": "Linked",
"account.settings.sso.account.disconnect.error": "There was a problem disconnecting this account. Contact support if the problem persists.",
"account.settings.sso.unlink.account": "Unlink {name} account",
"account.settings.sso.no.providers": "No accounts can be linked at this time.",
"unexpected.error.message.text": "An unexpected error occurred. Please click the button below to return to refresh the page.",
"unexpected.error.button.text": "Try Again",
"siteheader.links.courses": "Courses",
"siteheader.links.programs": "Programs",
"siteheader.links.content.search": "Discover New",
"siteheader.user.menu.dashboard": "Dashboard",
"siteheader.user.menu.profile": "Profile",
"siteheader.user.menu.account.settings": "Account",
"siteheader.user.menu.order.history": "Order History",
"siteheader.user.menu.logout": "Logout",
"siteheader.user.menu.login": "Login",
"siteheader.user.menu.register": "Sign Up",
"app.loading.message": "Loading...",
"error.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
"account.settings.sso.no.providers": "No accounts can be linked at this time."
}

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