Compare commits

..

1 Commits

Author SHA1 Message Date
David Joy
f46db1f1d4 Updating i18n-loader with all our languages. 2019-05-07 09:08:33 -04:00
268 changed files with 24227 additions and 35828 deletions

26
.babelrc Executable file
View File

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

36
.env
View File

@@ -1,36 +0,0 @@
ACCESS_TOKEN_COOKIE_NAME=''
ACCOUNT_PROFILE_URL=''
BASE_URL=''
CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL=''
FAVICON_URL=''
LANGUAGE_PREFERENCE_COOKIE_NAME=''
LMS_BASE_URL=''
LOGIN_URL=''
LOGO_TRADEMARK_URL=''
LOGO_URL=''
LOGO_WHITE_URL=''
SHOW_EMAIL_CHANNEL=''
LOGOUT_URL=''
MARKETING_SITE_BASE_URL=''
NODE_ENV='production'
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
STUDIO_BASE_URL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
APP_ID=
MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK=''
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'

View File

@@ -1,37 +0,0 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
ACCOUNT_PROFILE_URL='http://localhost:1995'
BASE_URL='localhost:1997'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:5335'
NODE_ENV='development'
ORDER_HISTORY_URL='http://localhost:1996/orders'
PORT=1997
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
STUDIO_BASE_URL=''
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
SHOW_EMAIL_CHANNEL='true'
APP_ID=
MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'

View File

@@ -1,34 +0,0 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1997'
CREDENTIALS_BASE_URL='http://localhost:18150'
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
LOGIN_URL='http://localhost:18000/login'
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:5335'
NODE_ENV=''
ORDER_HISTORY_URL='http://localhost:1996/orders'
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
STUDIO_BASE_URL=''
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
SHOW_EMAIL_CHANNEL=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
APP_ID=
MFE_CONFIG_API_URL=
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'

View File

@@ -2,5 +2,3 @@ coverage/*
dist/
node_modules/
__mocks__/
__snapshots__/
src/i18n/messages/

34
.eslintrc Executable file
View File

@@ -0,0 +1,34 @@
{
"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
}
}

View File

@@ -1,4 +0,0 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('eslint');

View File

@@ -1,24 +0,0 @@
### Description
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
#### How Has This Been Tested?
Please describe in detail how you tested your changes.
#### Screenshots/sandbox (optional):
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|Before|After|
|-------|-----|
| | |
#### Merge Checklist
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
* [ ] Is there adequate test coverage for your changes?
#### Post-merge Checklist
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

@@ -1,19 +0,0 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,20 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -1,29 +0,0 @@
name: ci
on:
push:
branches:
- master
pull_request:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
npm-test:
- i18n_extract
- lint
- test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: make requirements
- run: make test NPM_TESTS=build
- run: make test NPM_TESTS=${{ matrix.npm-test }}
- name: Coverage
if: matrix.npm-test == 'test'
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true

View File

@@ -1,10 +0,0 @@
# Run commitlint on the commit messages in a pull request.
name: Lint Commit Messages
on:
- pull_request
jobs:
commitlint:
uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -1,13 +0,0 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

View File

@@ -1,12 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

View File

@@ -1,12 +0,0 @@
name: Update Browserslist DB
on:
schedule:
- cron: '0 0 * * 1'
workflow_dispatch:
jobs:
update-browserslist:
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
secrets:
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}

4
.gitignore vendored
View File

@@ -6,8 +6,6 @@ npm-debug.log
coverage
dist/
src/i18n/transifex_input.json
temp/babel-plugin-react-intl
### pyenv ###
.python-version
@@ -16,5 +14,3 @@ temp/babel-plugin-react-intl
*~
/temp
/.vscode
/module.config.js
src/i18n/messages/

View File

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

1
.nvmrc
View File

@@ -1 +0,0 @@
20

22
.travis.yml Executable file
View File

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

8
.tx/config Normal file
View File

@@ -0,0 +1,8 @@
[main]
host = https://www.transifex.com
[edx-platform.frontend-app-account]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

149
LICENSE
View File

@@ -1,21 +1,23 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
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 GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
the GNU General Public License is 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.
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.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ 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.
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.
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.
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.
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.
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.
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.
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.
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.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -60,7 +72,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -537,45 +549,35 @@ 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. 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.
13. Use with the GNU Affero General Public License.
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 General Public License into a single
under version 3 of the GNU Affero 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 work with which it is combined will remain governed by version
3 of the GNU General Public License.
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.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
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
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
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 Affero General
Program specifies that a certain numbered version of the GNU 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 Affero General Public License, you may choose any version ever published
GNU 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 Affero General Public License can be used, that proxy's
versions of the GNU 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.
@@ -633,29 +635,40 @@ 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 Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU 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 Affero General Public License for more details.
GNU General Public License for more details.
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/>.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
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.
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".
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 AGPL, see
<https://www.gnu.org/licenses/>.
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>.

81
Makefile Normal file → Executable file
View File

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

View File

@@ -1,223 +1,22 @@
####################
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
frontend-app-account
####################
|ci-badge| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
********
Purpose
********
This is a micro-frontend application responsible for the display and updating of a user's account information.
What is the domain of this MFE?
In this MFE: Private user settings UIs. Public facing profile is in a `separate MFE (Profile) <https://github.com/openedx/frontend-app-profile>`_
- Account settings page
- IDV (Identity Verification)
***************
Getting Started
***************
Prerequisites
=============
`Tutor`_ is currently recommended as a development environment for your
new MFE. Please refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
Plugins
=======
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
Environment Variables/Setup Notes
=================================
This MFE is configured via the ``frontend-platform`` configuration module. For more information on MFE configuration see the `Configuration documentation`_.
The account settings micro-frontend also supports the following additional variable:
``SUPPORT_URL``
Example: ``https://support.example.com``
The fully-qualified URL to the support page in the target environment.
``PASSWORD_RESET_SUPPORT_LINK``
Examples:
- ``https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-``
- ``mailto:support@example.com``
The fully-qualified URL to the support page or email to request the support from in the target environment.
``ENABLE_ACCOUNT_DELETION``
Example: ``'false'`` | ``''`` (empty strings are true)
Enable the account deletion option, defaults to true.
To disable account deletion set ``ENABLE_ACCOUNT_DELETION`` to ``'false'`` (string), otherwise it will default to true.
Example build syntax with a single environment variable:
.. code:: bash
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
For more information see the document: `Configuration documentation`_
.. _Configuration documentation: https://openedx.github.io/frontend-platform/module-Config.html
Cloning and Startup
===================
.. code-block::
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-app-account.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-app-account && npm ci``
4. Start the dev server:
``npm start``
Local module development
=========================
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance:
Please tag **@edx/arch-team** on any PRs or issues.
.. code-block:: js
Introduction
------------
module.exports = {
/*
Modules you want to use from local source code. Adding a module here means that when this app
runs its build, it'll resolve the source from peer directories of this app.
React app for account settings.
moduleName: the name you use to import code from the module.
dir: The relative path to the module's source code.
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
may want to use "src" if the module installs React as a peer/dev dependency.
*/
localModules: [
{ moduleName: '@openedx/paragon/scss', dir: '../paragon', dist: 'scss' },
{ moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' },
{ moduleName: '@openedx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
{ moduleName: '@openedx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
],
};
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
Known Issues
===========
None
Development Roadmap
===================
We don't have anything planned for the core of the MFE (the account settings page) - this MFE is currently in maintenance mode.
There may be a replacement for IDV coming down the pipe, so that may be DEPRed.
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
Getting Help
===========
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-account/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
The Open edX Code of Conduct
============================
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
======
The assigned maintainers for this component and other project details may be found in Backstage or from inspecting catalog-info.yaml.
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.
==============================
.. |ci-badge| image:: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml/badge.svg
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
:alt: Continuous Integration
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
:target: https://codecov.io/gh/edx/frontend-app-account
.. |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
.. |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

@@ -1,19 +0,0 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-account'
description: "Open edX micro-frontend application for managing user account information."
links:
- url: "https://github.com/openedx/frontend-app-account"
title: "Frontend app account"
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
openedx.org/release: "master"
spec:
owner: group:2u-infinity
type: 'website'
lifecycle: 'production'

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

View File

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

6
openedx.yaml Normal file
View File

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

40010
package-lock.json generated

File diff suppressed because it is too large Load Diff

205
package.json Normal file → Executable file
View File

@@ -1,95 +1,142 @@
{
"name": "@edx/frontend-app-account",
"version": "1.0.0-semantically-released",
"description": "User account micro-frontend for Open edX",
"author": "edX",
"license": "AGPL-3.0",
"version": "0.1.0",
"description": "User account React app",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-account.git"
"url": "git+https://github.com/edx/frontend-app-account.git"
},
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "npm run lint -- --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
"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",
"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"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-account/issues"
},
"homepage": "https://github.com/openedx/frontend-app-account#readme",
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-account#readme",
"publishConfig": {
"access": "public"
},
"browserslist": [
"extends @edx/browserslist-config"
],
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.3.3",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "0.2.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^22.16.0",
"@tensorflow-models/blazeface": "0.1.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"bowser": "2.11.0",
"classnames": "2.5.1",
"core-js": "3.41.0",
"font-awesome": "4.7.0",
"form-urlencoded": "6.1.5",
"formdata-polyfill": "4.0.10",
"jslib-html5-camera-photo": "3.3.4",
"lodash.camelcase": "4.3.0",
"lodash.debounce": "4.0.8",
"lodash.findindex": "4.6.0",
"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.pickby": "4.6.0",
"lodash.snakecase": "4.1.1",
"long": "5.3.2",
"memoize-one": "^6.0.0",
"prop-types": "15.8.1",
"qs": "6.14.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "^6.25.1",
"react-router-dom": "^6.25.1",
"react-router-hash-link": "2.4.3",
"react-scrollspy": "3.4.3",
"react-transition-group": "4.4.5",
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.3.0",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "^5.1.1",
"universal-cookie": "7.2.2"
"@cospired/i18n-iso-languages": "^2.0.2",
"@edx/edx-bootstrap": "^2.0.1",
"@edx/frontend-analytics": "^1.0.0",
"@edx/frontend-auth": "^5.0.0",
"@edx/frontend-component-footer": "^2.0.3",
"@edx/frontend-component-site-header": "^2.1.4",
"@edx/frontend-logging": "^2.0.0",
"@edx/paragon": "^4.1.3",
"@fortawesome/fontawesome-svg-core": "^1.2.17",
"@fortawesome/free-brands-svg-icons": "^5.7.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",
"glob": "^7.1.3",
"history": "^4.7.2",
"i18n-iso-countries": "^3.7.8",
"iso-countries-languages": "^0.2.1",
"lodash.camelcase": "^4.3.0",
"lodash.get": "^4.4.2",
"lodash.pick": "^4.4.0",
"lodash.snakecase": "^4.1.1",
"newrelic": "^5.5.0",
"prop-types": "^15.5.10",
"react": "^16.8.3",
"react-dom": "^16.8.3",
"react-intl": "^2.8.0",
"react-redux": "^5.1.1",
"react-router": "^4.2.0",
"react-router-dom": "^4.2.2",
"react-transition-group": "^2.5.3",
"redux": "^4.0.1",
"redux-devtools-extension": "^2.13.2",
"redux-logger": "^3.0.6",
"redux-saga": "^1.0.1",
"redux-thunk": "^2.2.0",
"reselect": "^4.0.0",
"universal-cookie": "^3.1.0",
"webpack-rtl-plugin": "^2.0.0"
},
"devDependencies": {
"@edx/browserslist-config": "1.5.0",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "14.3.1",
"react-test-renderer": "^18.3.1",
"@svgr/webpack": "^4.2.0",
"autoprefixer": "^9.4.2",
"axios-mock-adapter": "^1.15.0",
"babel-cli": "^6.26.0",
"babel-eslint": "^8.2.2",
"babel-jest": "^22.4.0",
"babel-loader": "^7.1.2",
"babel-plugin-react-intl": "^3.0.1",
"babel-plugin-transform-class-properties": "^6.24.1",
"babel-plugin-transform-object-rest-spread": "^6.26.0",
"babel-preset-env": "^1.6.1",
"babel-preset-react": "^6.24.1",
"clean-webpack-plugin": "^0.1.19",
"codecov": "^3.0.0",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^0.28.9",
"enzyme": "^3.3.0",
"enzyme-adapter-react-16": "^1.1.1",
"es-check": "^2.0.2",
"eslint-config-edx": "^4.0.3",
"fetch-mock": "^6.3.0",
"file-loader": "^1.1.9",
"html-webpack-harddisk-plugin": "^0.2.0",
"html-webpack-new-relic-plugin": "^1.1.0",
"html-webpack-plugin": "^3.0.3",
"husky": "^0.14.3",
"identity-obj-proxy": "^3.0.0",
"image-webpack-loader": "^4.2.0",
"jest": "^22.4.0",
"mini-css-extract-plugin": "^0.4.0",
"node-sass": "^4.7.2",
"postcss-loader": "^3.0.0",
"react-dev-utils": "^5.0.0",
"react-test-renderer": "^16.8.6",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.5"
"redux-mock-store": "^1.5.3",
"sass-loader": "^6.0.6",
"style-loader": "^0.20.2",
"travis-deploy-once": "^5.0.9",
"url-loader": "^1.1.2",
"webpack": "^4.25.1",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.0",
"webpack-merge": "^4.1.1"
},
"jest": {
"testURL": "http://localhost/",
"setupFiles": [
"./src/setupTest.js"
],
"moduleNameMapper": {
"\\.svg": "<rootDir>/__mocks__/svgrMock.js",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/__mocks__/fileMock.js",
"\\.(css|scss)$": "identity-obj-proxy",
"@edx/frontend-i18n(.*)$": "<rootDir>/src/i18n$1"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"src/setupTest.js",
"src/index.js",
"/tests/"
],
"transformIgnorePatterns": [
"/node_modules/(?!(@edx/paragon)/).*/"
]
}
}

View File

@@ -1,148 +1,11 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en-us">
<head>
<title>Account | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="shortcut icon"
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
type="image/x-icon"
/>
<% if (process.env.OPTIMIZELY_PROJECT_ID) { %>
<script src="<%= process.env.MARKETING_SITE_BASE_URL %>/optimizelyjs/<%= process.env.OPTIMIZELY_PROJECT_ID %>.js"></script>
<% } %>
<title>Account | edX</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<!-- begin usabilla live embed code -->
<script defer type="text/javascript">
window.lightningjs ||
(function (n) {
var e = "lightningjs";
function t(e, t) {
var r, i, a, o, d, c;
return (
t && (t += (/\?/.test(t) ? "&" : "?") + "lv=1"),
n[e] ||
((r = window),
(i = document),
(a = e),
(o = i.location.protocol),
(d = "load"),
(c = 0),
(function () {
n[a] = function () {
var t = arguments,
i = this,
o = ++c,
d = (i && i != r && i.id) || 0;
function s() {
return (s.id = o), n[a].apply(s, arguments);
}
return (
(e.s = e.s || []).push([o, d, t]),
(s.then = function (n, t, r) {
var i = (e.fh[o] = e.fh[o] || []),
a = (e.eh[o] = e.eh[o] || []),
d = (e.ph[o] = e.ph[o] || []);
return (
n && i.push(n), t && a.push(t), r && d.push(r), s
);
}),
s
);
};
var e = (n[a]._ = {});
function s() {
e.P(d), (e.w = 1), n[a]("_load");
}
(e.fh = {}),
(e.eh = {}),
(e.ph = {}),
(e.l = t
? t.replace(/^\/\//, ("https:" == o ? o : "http:") + "//")
: t),
(e.p = { 0: +new Date() }),
(e.P = function (n) {
e.p[n] = new Date() - e.p[0];
}),
e.w && s(),
r.addEventListener
? r.addEventListener(d, s, !1)
: r.attachEvent("onload", s);
var l = function () {
function n() {
return [
"<!DOCTYPE ",
o,
"><",
o,
"><head></head><",
t,
"><",
r,
' src="',
e.l,
'"></',
r,
"></",
t,
"></",
o,
">",
].join("");
}
var t = "body",
r = "script",
o = "html",
d = i[t];
if (!d) return setTimeout(l, 100);
e.P(1);
var c,
s = i.createElement("div"),
h = s.appendChild(i.createElement("div")),
u = i.createElement("iframe");
(s.style.display = "none"),
(d.insertBefore(s, d.firstChild).id = "lightningjs-" + a),
(u.frameBorder = "0"),
(u.id = "lightningjs-frame-" + a),
/MSIE[ ]+6/.test(navigator.userAgent) &&
(u.src = "javascript:false"),
(u.allowTransparency = "true"),
h.appendChild(u);
try {
u.contentWindow.document.open();
} catch (n) {
(e.domain = i.domain),
(c =
"javascript:var d=document.open();d.domain='" +
i.domain +
"';"),
(u.src = c + "void(0);");
}
try {
var p = u.contentWindow.document;
p.write(n()), p.close();
} catch (e) {
u.src =
c +
'd.write("' +
n().replace(/"/g, String.fromCharCode(92) + '"') +
'");d.close();';
}
e.P(2);
};
e.l && l();
})()),
(n[e].lv = "1"),
n[e]
);
}
var r = (window.lightningjs = t(e));
(r.require = t), (r.modules = n);
})({});
</script>
<!-- end usabilla live embed code -->
<div id="root"></div>
</body>
</html>

View File

@@ -1,33 +0,0 @@
{
"extends": [
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
],
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"
}

View File

@@ -1,830 +1,131 @@
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig, 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,
getLocale,
getCountryList,
getLanguageList,
} from '@edx/frontend-platform/i18n';
import {
Hyperlink, Icon, Alert,
} from '@openedx/paragon';
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
} from '@edx/frontend-i18n'; // eslint-disable-line
import messages from './AccountSettingsPage.messages';
import {
fetchSettings,
saveMultipleSettings,
saveSettings,
updateDraft,
beginNameChange,
} from './data/actions';
import { accountSettingsPageSelector } from './data/selectors';
import PageLoading from './PageLoading';
import JumpNav from './JumpNav';
import DeleteAccount from './delete-account';
import EditableField from './EditableField';
import EditableSelectField from './EditableSelectField';
import ResetPassword from './reset-password';
import NameChange from './name-change';
import ThirdPartyAuth from './third-party-auth';
import BetaLanguageBanner from './BetaLanguageBanner';
import EmailField from './EmailField';
import OneTimeDismissibleAlert from './OneTimeDismissibleAlert';
import DOBModal from './DOBForm';
import { fetchAccount, fetchThirdPartyAuthProviders } from './actions';
import { accountSettingsSelector } from './selectors';
import { PageLoading } from '../common';
import EditableField from './components/EditableField';
import PasswordReset from './components/PasswordReset';
import ThirdPartyAuth from './components/ThirdPartyAuth';
import EmailField from './components/EmailField';
import {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
GENDER_OPTIONS,
COUNTRY_WITH_STATES,
COPPA_COMPLIANCE_YEAR,
WORK_EXPERIENCE_OPTIONS,
getStatesList,
FIELD_LABELS,
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import { fetchCourseList } from '../notification-preferences/data/thunks';
import NotificationSettings from '../notification-preferences/NotificationSettings';
import { withLocation, withNavigate } from './hoc';
} from './constants';
class AccountSettingsPage extends React.Component {
constructor(props, context) {
super(props, context);
const duplicateTpaProvider = getQueryParameters().duplicate_provider;
this.state = {
duplicateTpaProvider,
};
this.navLinkRefs = {
'#basic-information': React.createRef(),
'#profile-information': React.createRef(),
'#social-media': React.createRef(),
'#notifications': React.createRef(),
'#site-preferences': React.createRef(),
'#linked-accounts': React.createRef(),
'#delete-account': React.createRef(),
};
constructor(props) {
super(props);
this.countryOptions = getCountryList(getLocale())
.map(({ code, name }) => ({ value: code, label: name }));
this.languageProficiencyOptions = getLanguageList(getLocale())
.map(({ code, name }) => ({ value: code, label: name }));
this.educationLevels = EDUCATION_LEVELS.map(key => ({
value: key,
label: props.intl.formatMessage(messages[`account.settings.field.education.levels.${key}`]),
}));
this.genderOptions = GENDER_OPTIONS.map(key => ({
value: key,
label: props.intl.formatMessage(messages[`account.settings.field.gender.options.${key}`]),
}));
}
componentDidMount() {
this.props.fetchCourseList();
this.props.fetchSettings();
this.props.fetchSiteLanguages(this.props.navigate);
sendTrackingLogEvent('edx.user.settings.viewed', {
page: 'account',
visibility: null,
user_id: this.context.authenticatedUser.userId,
});
}
componentDidUpdate(prevProps) {
if (prevProps.loading && !prevProps.loaded && this.props.loaded) {
const locationHash = global.location.hash;
// Check for the locationHash in the URL and then scroll to it if it is in the
// NavLinks list
if (typeof locationHash !== 'string') {
return;
}
if (Object.keys(this.navLinkRefs).includes(locationHash) && this.navLinkRefs[locationHash].current) {
window.scrollTo(0, this.navLinkRefs[locationHash].current.offsetTop);
}
}
}
// NOTE: We need 'locale' for the memoization in getLocalizedTimeZoneOptions. Don't remove it!
// eslint-disable-next-line no-unused-vars
getLocalizedTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions, locale) => {
const concatTimeZoneOptions = [{
label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.default']),
value: '',
}];
if (countryTimeZoneOptions.length) {
concatTimeZoneOptions.push({
label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.country']),
group: countryTimeZoneOptions,
});
}
concatTimeZoneOptions.push({
label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.all']),
group: timeZoneOptions,
});
return concatTimeZoneOptions;
});
getLocalizedOptions = memoize((locale, country) => ({
countryOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(
this.removeDisabledCountries(
getCountryList(locale).map(({ code, name }) => ({
value: code,
label: name,
disabled: this.isDisabledCountry(code),
})),
),
),
stateOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
}].concat(getStatesList(country)),
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'}`]),
})),
workExperienceOptions: WORK_EXPERIENCE_OPTIONS.map(key => ({
value: key,
label: key === '' ? this.props.intl.formatMessage(messages['account.settings.field.work.experience.options.empty']) : key,
})),
}));
canDeleteAccount = () => {
const { committedValues } = this.props;
return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country);
};
removeDisabledCountries = (countryList) => {
const { countriesCodesList, committedValues } = this.props;
const committedCountry = committedValues?.country;
if (!countriesCodesList.length) {
return countryList;
}
return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value));
};
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
};
handleSubmit = (formId, values) => {
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
return;
}
const { formValues } = this.props;
let extendedProfileObject = {};
if ('extended_profile' in formValues && formValues.extended_profile.some((field) => field.field_name === formId)) {
extendedProfileObject = {
extended_profile: formValues.extended_profile.map(field => (field.field_name === formId
? { ...field, field_value: values }
: field)),
};
}
this.props.saveSettings(formId, values, extendedProfileObject);
};
handleSubmitProfileName = (formId, values) => {
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
this.props.saveMultipleSettings([
{
formId,
commitValues: values,
},
{
formId: 'useVerifiedNameForCerts',
commitValues: this.props.formValues.useVerifiedNameForCerts,
},
], formId);
} else {
this.props.saveSettings(formId, values);
}
};
handleSubmitVerifiedName = (formId, values) => {
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
this.props.saveSettings('useVerifiedNameForCerts', this.props.formValues.useVerifiedNameForCerts);
}
if (values !== this.props.committedValues?.verified_name) {
this.props.beginNameChange(formId);
} else {
this.props.saveSettings(formId, values);
}
};
isDisabledCountry = (country) => {
const { countriesCodesList } = this.props;
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
};
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
}
isManagedProfile() {
// Enterprise customer profiles are managed by their organizations. We determine whether
// a profile is managed or not by the presence of the profileDataManager prop.
return Boolean(this.props.profileDataManager);
}
renderDuplicateTpaProviderMessage() {
if (!this.state.duplicateTpaProvider) {
return null;
}
// If there is a "duplicate_provider" query parameter, that's the backend's
// way of telling us that the provider account the user tried to link is already linked
// to another user account on the platform. We use this to display a message to that effect,
// and remove the parameter from the URL.
this.props.navigate(this.props.location, { replace: true });
return (
<div>
<Alert variant="danger">
<FormattedMessage
id="account.settings.message.duplicate.tpa.provider"
defaultMessage="The {provider} account you selected is already linked to another {siteName} account."
description="alert message informing the user that the third-party account they attempted to link is already linked to another account"
values={{
provider: <b>{this.state.duplicateTpaProvider}</b>,
siteName: getConfig().SITE_NAME,
}}
/>
</Alert>
</div>
);
}
renderManagedProfileMessage() {
if (!this.isManagedProfile()) {
return null;
}
return (
<div>
<Alert variant="info">
<FormattedMessage
id="account.settings.message.managed.settings"
defaultMessage="Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help."
description="alert message informing the user their account data is managed by a third party"
values={{
managerTitle: <b>{this.props.profileDataManager}</b>,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
<FormattedMessage
id="account.settings.message.managed.settings.support"
defaultMessage="support"
description="website support"
/>
</Hyperlink>
),
}}
/>
</Alert>
</div>
);
}
renderFullNameHelpText = (status, proctoredExamId) => {
if (!this.props.verifiedNameHistory) {
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text']);
}
let messageString = 'account.settings.field.full.name.help.text';
if (status === 'submitted') {
messageString += '.submitted';
if (proctoredExamId) {
messageString += '.proctored';
}
} else {
messageString += '.default';
}
if (!this.props.committedValues.useVerifiedNameForCerts) {
messageString += '.certificate';
}
return this.props.intl.formatMessage(messages[messageString]);
};
renderVerifiedNameSuccessMessage = (verifiedName, created) => {
const dateValue = new Date(created).valueOf();
const id = `dismissedVerifiedNameSuccessMessage-${verifiedName}-${dateValue}`;
return (
<OneTimeDismissibleAlert
id={id}
variant="success"
icon={CheckCircle}
header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message.header'])}
body={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message'])}
/>
);
};
renderVerifiedNameFailureMessage = (verifiedName, created) => {
const dateValue = new Date(created).valueOf();
const id = `dismissedVerifiedNameFailureMessage-${verifiedName}-${dateValue}`;
return (
<OneTimeDismissibleAlert
id={id}
variant="danger"
icon={Error}
header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.header'])}
body={
(
<div className="d-flex flex-row">
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
</div>
)
}
/>
);
};
renderVerifiedNameSubmittedMessage = (willCertNameChange) => (
<Alert
variant="warning"
icon={WarningFilled}
>
<Alert.Heading>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.header'])}
</Alert.Heading>
<p>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message'])}{' '}
{
willCertNameChange
&& this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.certificate'])
}
</p>
</Alert>
);
renderVerifiedNameMessage = verifiedNameRecord => {
const {
created,
status,
profile_name: profileName,
verified_name: verifiedName,
proctored_exam_attempt_id: proctoredExamId,
} = verifiedNameRecord;
let willCertNameChange = false;
if (
(
// User submitted a profile name change, and uses their profile name on certificates
this.props.committedValues.name !== profileName
&& !this.props.committedValues.useVerifiedNameForCerts
)
|| (
// User submitted a verified name change, and uses their verified name on certificates
this.props.committedValues.name === profileName
&& this.props.committedValues.useVerifiedNameForCerts
)
) {
willCertNameChange = true;
}
if (proctoredExamId) {
return null;
}
switch (status) {
case 'approved':
return this.renderVerifiedNameSuccessMessage(verifiedName, created);
case 'denied':
return this.renderVerifiedNameFailureMessage(verifiedName, created);
case 'submitted':
return this.renderVerifiedNameSubmittedMessage(willCertNameChange);
default:
return null;
}
};
renderVerifiedNameIcon = (status) => {
switch (status) {
case 'approved':
return (<Icon src={CheckCircle} className="ml-1" style={{ height: '18px', width: '18px', color: 'green' }} />);
case 'submitted':
return (<Icon src={WarningFilled} className="ml-1" style={{ height: '18px', width: '18px', color: 'yellow' }} />);
default:
return null;
}
};
renderVerifiedNameHelpText = (status, proctoredExamId) => {
let messageStr = 'account.settings.field.name.verified.help.text';
// add additional string based on status
if (status === 'approved') {
messageStr += '.verified';
} else if (status === 'submitted') {
messageStr += '.submitted';
} else {
return null;
}
// add additional string if verified name came from a proctored exam attempt
if (proctoredExamId) {
messageStr += '.proctored';
}
// add additional string based on certificate name use
if (this.props.committedValues.useVerifiedNameForCerts) {
messageStr += '.certificate';
}
return this.props.intl.formatMessage(messages[messageStr]);
};
renderEmptyStaticFieldMessage() {
if (this.isManagedProfile()) {
return this.props.intl.formatMessage(messages['account.settings.static.field.empty'], {
enterprise: this.props.profileDataManager,
});
}
return this.props.intl.formatMessage(messages['account.settings.static.field.empty.no.admin']);
}
renderNameChangeModal() {
if (this.props.nameChangeModal && this.props.nameChangeModal.formId) {
return <NameChange targetFormId={this.props.nameChangeModal.formId} />;
}
return null;
}
renderSecondaryEmailField(editableFieldProps) {
if (!this.props.formValues.secondary_email_enabled) {
return null;
}
return (
<EmailField
name="secondary_email"
label={this.props.intl.formatMessage(messages['account.settings.field.secondary.email'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.secondary.email.empty'])}
value={this.props.formValues.secondary_email}
confirmationMessageDefinition={messages['account.settings.field.secondary.email.confirmation']}
{...editableFieldProps}
/>
);
this.props.fetchAccount();
this.props.fetchThirdPartyAuthProviders();
}
renderContent() {
const editableFieldProps = {
onChange: this.handleEditableFieldChange,
onSubmit: this.handleSubmit,
};
// Memoized options lists
const {
countryOptions,
stateOptions,
languageProficiencyOptions,
yearOfBirthOptions,
educationLevelOptions,
genderOptions,
workExperienceOptions,
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
// Show State field only if the country is US (could include Canada later)
const { country } = this.props.formValues;
const showState = country === COUNTRY_WITH_STATES && !this.isDisabledCountry(country);
const { verifiedName } = this.props;
const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
this.props.timeZoneOptions,
this.props.countryTimeZoneOptions,
this.context.locale,
);
const hasLinkedTPA = findIndex(this.props.tpaProviders, provider => provider.connected) >= 0;
// if user is under 13 and does not have cookie set
const shouldUpdateDOB = (
getConfig().ENABLE_COPPA_COMPLIANCE
&& getConfig().ENABLE_DOB_UPDATE
&& this.props.formValues.year_of_birth.toString() >= COPPA_COMPLIANCE_YEAR.toString()
&& !localStorage.getItem('submittedDOB')
);
return (
<>
{ shouldUpdateDOB
&& (
<DOBModal
{...editableFieldProps}
/>
)}
<div className="account-section pt-3 mb-5" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
{
this.props.mostRecentVerifiedName
&& this.renderVerifiedNameMessage(this.props.mostRecentVerifiedName)
}
{localStorage.getItem('submittedDOB')
&& (
<OneTimeDismissibleAlert
id="updated-dob"
variant="success"
icon={CheckCircle}
header={this.props.intl.formatMessage(messages['account.settings.field.dob.form.success'])}
body=""
/>
)}
<div>
<div className="row">
<div className="col-md-8 col-lg-6">
<h2>{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
{this.renderManagedProfileMessage()}
{this.renderNameChangeModal()}
<EditableField
name="username"
type="text"
value={this.props.formValues.username}
label={this.props.intl.formatMessage(messages['account.settings.field.username'])}
helpText={this.props.intl.formatMessage(
messages['account.settings.field.username.help.text'],
{ siteName: getConfig().SITE_NAME },
)}
isEditable={false}
{...editableFieldProps}
/>
<EditableField
name="name"
type="text"
value={
verifiedName?.status === 'submitted'
&& this.props.formValues.pending_name_change
? this.props.formValues.pending_name_change
: this.props.formValues.name
}
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
this.isEditable('name')
? this.props.intl.formatMessage(messages['account.settings.field.full.name.empty'])
: this.renderEmptyStaticFieldMessage()
}
helpText={
verifiedName
? this.renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
: this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])
}
isEditable={
verifiedName
? this.isEditable('verifiedName') && this.isEditable('name')
: this.isEditable('name')
}
isGrayedOut={
verifiedName && !this.isEditable('verifiedName')
}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitProfileName}
/>
{verifiedName
&& (
<EditableField
name="verified_name"
name="username"
type="text"
value={this.props.formValues.verified_name}
label={
(
<div className="d-flex">
{this.props.intl.formatMessage(messages['account.settings.field.name.verified'])}
{
this.renderVerifiedNameIcon(verifiedName.status)
}
</div>
)
}
helpText={this.renderVerifiedNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)}
isEditable={this.isEditable('verifiedName')}
isGrayedOut={!this.isEditable('verifiedName')}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitVerifiedName}
label={this.props.intl.formatMessage(messages['account.settings.field.username'])}
isEditable={false}
/>
)}
<EmailField
name="email"
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
emptyLabel={
this.isEditable('email')
? this.props.intl.formatMessage(messages['account.settings.field.email.empty'])
: this.renderEmptyStaticFieldMessage()
}
value={this.props.formValues.email}
confirmationMessageDefinition={messages['account.settings.field.email.confirmation']}
helpText={this.props.intl.formatMessage(
messages['account.settings.field.email.help.text'],
{ siteName: getConfig().SITE_NAME },
)}
isEditable={this.isEditable('email')}
{...editableFieldProps}
/>
{this.renderSecondaryEmailField(editableFieldProps)}
<ResetPassword email={this.props.formValues.email} />
{(!getConfig().ENABLE_COPPA_COMPLIANCE)
&& (
<EditableSelectField
<EditableField
name="name"
type="text"
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
/>
<EmailField
name="email"
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
confirmationMessageDefinition={messages['account.settings.field.email.confirmation']}
/>
<EditableField
name="year_of_birth"
type="select"
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={this.props.formValues.year_of_birth}
options={yearOfBirthOptions}
{...editableFieldProps}
options={YEAR_OF_BIRTH_OPTIONS}
/>
)}
<EditableSelectField
name="country"
type="select"
value={this.props.formValues.country}
options={countryOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
emptyLabel={
this.isEditable('country')
? this.props.intl.formatMessage(messages['account.settings.field.country.empty'])
: this.renderEmptyStaticFieldMessage()
}
isEditable={this.isEditable('country')}
{...editableFieldProps}
/>
{showState
&& (
<EditableSelectField
name="state"
<PasswordReset />
<EditableField
name="country"
type="select"
value={this.props.formValues.state}
options={stateOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.state'])}
emptyLabel={
this.isEditable('state')
? this.props.intl.formatMessage(messages['account.settings.field.state.empty'])
: this.renderEmptyStaticFieldMessage()
}
isEditable={this.isEditable('state')}
{...editableFieldProps}
options={this.countryOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
/>
)}
</div>
<EditableField
name="level_of_education"
type="select"
options={this.educationLevels}
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
/>
<EditableField
name="gender"
type="select"
options={this.genderOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
/>
<EditableField
name="language_proficiencies"
type="select"
options={this.languageProficiencyOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies'])}
/>
<ThirdPartyAuth />
<div className="account-section pt-3 mb-5" id="profile-information" ref={this.navLinkRefs['#profile-information']}>
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.profile.information'])}
</h2>
<EditableSelectField
name="level_of_education"
type="select"
value={this.props.formValues.level_of_education}
options={getConfig().ENABLE_COPPA_COMPLIANCE
? educationLevelOptions.filter(option => option.value !== 'el')
: educationLevelOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.education.empty'])}
{...editableFieldProps}
/>
<EditableSelectField
name="gender"
type="select"
value={this.props.formValues.gender}
options={genderOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.gender.empty'])}
{...editableFieldProps}
/>
{hasWorkExperience
&& (
<EditableSelectField
name="work_experience"
type="select"
value={this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience')?.field_value}
options={workExperienceOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.work.experience'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.work.experience.empty'])}
{...editableFieldProps}
/>
)}
<EditableSelectField
name="language_proficiencies"
type="select"
value={this.props.formValues.language_proficiencies}
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}
/>
</div>
<div className="account-section pt-3 mb-6" id="social-media">
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
</h2>
<p>
{this.props.intl.formatMessage(
messages['account.settings.section.social.media.description'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<EditableField
name="social_link_linkedin"
type="text"
value={this.props.formValues.social_link_linkedin}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin.empty'])}
{...editableFieldProps}
/>
<EditableField
name="social_link_facebook"
type="text"
value={this.props.formValues.social_link_facebook}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook.empty'])}
{...editableFieldProps}
/>
<EditableField
name="social_link_twitter"
type="text"
value={this.props.formValues.social_link_twitter}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter.empty'])}
{...editableFieldProps}
/>
</div>
<div className="border border-light-700" />
<div className="mt-6" id="notifications" ref={this.navLinkRefs['#notifications']}>
<NotificationSettings />
</div>
<div className="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
</h2>
<BetaLanguageBanner />
<EditableSelectField
name="siteLanguage"
type="select"
options={this.props.siteLanguageOptions}
value={this.props.siteLanguage.draft !== undefined ? this.props.siteLanguage.draft : this.context.locale}
label={this.props.intl.formatMessage(messages['account.settings.field.site.language'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.site.language.help.text'])}
{...editableFieldProps}
/>
<EditableSelectField
name="time_zone"
type="select"
value={this.props.formValues.time_zone}
options={timeZoneOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.time.zone.description'])}
{...editableFieldProps}
onSubmit={(formId, value) => {
// the endpoint will not accept an empty string. it must be null
this.handleSubmit(formId, value || null);
}}
/>
</div>
<div className="account-section pt-3 mb-5" id="linked-accounts" ref={this.navLinkRefs['#linked-accounts']}>
<h2 className="section-heading h4 mb-3">{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts'])}</h2>
<p>
{this.props.intl.formatMessage(
messages['account.settings.section.linked.accounts.description'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<ThirdPartyAuth />
</div>
{getConfig().ENABLE_ACCOUNT_DELETION && (
<div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
<DeleteAccount
isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA}
canDeleteAccount={this.canDeleteAccount()}
<h2>{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.social.media.description'])}</p>
<EditableField
name="social_link_linkedIn"
type="text"
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin'])}
/>
<EditableField
name="social_link_facebook"
type="text"
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook'])}
/>
<EditableField
name="social_link_twitter"
type="text"
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
/>
</div>
)}
</>
</div>
</div>
);
}
@@ -853,168 +154,34 @@ class AccountSettingsPage extends React.Component {
return (
<div className="page__account-settings container-fluid py-5">
{this.renderDuplicateTpaProviderMessage()}
<h1 className="mb-4">
<h1>
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
</h1>
<div>
<div className="row">
<div className="col-md-2">
<JumpNav />
</div>
<div className="col-md-10">
{loading ? this.renderLoading() : null}
{loaded ? this.renderContent() : null}
{loadingError ? this.renderError() : null}
</div>
</div>
</div>
{loading ? this.renderLoading() : null}
{loaded ? this.renderContent() : null}
{loadingError ? this.renderError() : null}
</div>
);
}
}
AccountSettingsPage.contextType = AppContext;
AccountSettingsPage.propTypes = {
intl: intlShape.isRequired,
loading: PropTypes.bool,
loaded: PropTypes.bool,
loadingError: PropTypes.string,
// Form data
formValues: PropTypes.shape({
username: PropTypes.string,
name: PropTypes.string,
email: PropTypes.string,
secondary_email: PropTypes.string,
secondary_email_enabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
country: PropTypes.string,
level_of_education: PropTypes.string,
gender: PropTypes.string,
extended_profile: PropTypes.arrayOf(PropTypes.shape({
field_name: PropTypes.string,
field_value: PropTypes.string,
})),
language_proficiencies: PropTypes.string,
pending_name_change: PropTypes.string,
phone_number: PropTypes.string,
social_link_linkedin: PropTypes.string,
social_link_facebook: PropTypes.string,
social_link_twitter: PropTypes.string,
time_zone: PropTypes.string,
state: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool.isRequired,
verified_name: PropTypes.string,
}).isRequired,
committedValues: PropTypes.shape({
name: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string,
country: PropTypes.string,
}),
drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({
name: PropTypes.string,
}),
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
draft: PropTypes.string,
}),
siteLanguageOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
profileDataManager: PropTypes.string,
staticFields: PropTypes.arrayOf(PropTypes.string),
isActive: PropTypes.bool,
secondary_email_enabled: PropTypes.bool,
timeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
countryTimeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
fetchSiteLanguages: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
saveMultipleSettings: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
beginNameChange: PropTypes.func.isRequired,
fetchCourseList: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool,
})),
nameChangeModal: PropTypes.oneOfType([
PropTypes.shape({
formId: PropTypes.string,
}),
PropTypes.bool,
]),
verifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
mostRecentVerifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
verifiedNameHistory: PropTypes.arrayOf(
PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
),
navigate: PropTypes.func.isRequired,
location: PropTypes.string.isRequired,
countriesCodesList: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
),
fetchAccount: PropTypes.func.isRequired,
fetchThirdPartyAuthProviders: PropTypes.func.isRequired,
};
AccountSettingsPage.defaultProps = {
loading: false,
loaded: false,
loadingError: null,
committedValues: {
useVerifiedNameForCerts: false,
verified_name: null,
country: '',
},
drafts: {},
formErrors: {},
siteLanguage: null,
siteLanguageOptions: [],
timeZoneOptions: [],
countryTimeZoneOptions: [],
profileDataManager: null,
staticFields: [],
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {} || false,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
countriesCodesList: [],
};
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
fetchCourseList,
fetchSettings,
saveSettings,
saveMultipleSettings,
updateDraft,
fetchSiteLanguages,
beginNameChange,
})(injectIntl(AccountSettingsPage))));
export default connect(accountSettingsSelector, {
fetchAccount,
fetchThirdPartyAuthProviders,
})(injectIntl(AccountSettingsPage));

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from 'react-intl';
const messages = defineMessages({
'account.settings.page.heading': {
@@ -8,7 +8,7 @@ const messages = defineMessages({
},
'account.settings.loading.message': {
id: 'account.settings.loading.message',
defaultMessage: 'Loading...',
defaultMessage: 'Loading',
description: 'Message when data is being loaded',
},
'account.settings.loading.error': {
@@ -16,21 +16,6 @@ const messages = defineMessages({
defaultMessage: 'Error: {error}',
description: 'Message when data failed to load',
},
'account.settings.banner.beta.language': {
id: 'account.settings.banner.beta.language',
defaultMessage: 'You have set your language to {beta_language}, which is currently not fully translated. You can help us translate this language fully by joining the Transifex community and adding translations from English for learners that speak {beta_language}.',
description: 'Message when the user selects a beta language this is not yet fully translated.',
},
'account.settings.banner.beta.language.action.switch.back': {
id: 'account.settings.banner.beta.language.action.switch.back',
defaultMessage: 'Switch Back to {previous_language}',
description: 'Button on the beta language message to switch back to the previous language.',
},
'account.settings.banner.beta.language.action.help.translate': {
id: 'account.settings.banner.beta.language.action.help.translate',
defaultMessage: 'Help Translate into {beta_language}',
description: 'Button on the beta language message to help translate the beta language.',
},
'account.settings.section.account.information': {
id: 'account.settings.section.account.information',
defaultMessage: 'Account Information',
@@ -41,201 +26,26 @@ const messages = defineMessages({
defaultMessage: 'These settings include basic information about your account.',
description: 'The basic account information section heading description.',
},
'account.settings.section.profile.information': {
id: 'account.settings.section.profile.information',
defaultMessage: 'Profile Information',
description: 'The profile information section heading.',
},
'account.settings.section.site.preferences': {
id: 'account.settings.section.site.preferences',
defaultMessage: 'Site Preferences',
description: 'The site preferences section heading.',
},
'account.settings.section.linked.accounts': {
id: 'account.settings.section.linked.accounts',
defaultMessage: 'Linked Accounts',
description: 'The linked accounts section heading.',
},
'account.settings.section.linked.accounts.description': {
id: 'account.settings.section.linked.accounts.description',
defaultMessage: 'You can link your identity accounts to simplify signing in to {siteName}.',
description: 'The linked accounts section heading description.',
},
'account.settings.field.username': {
id: 'account.settings.field.username',
defaultMessage: 'Username',
description: 'Label for account settings username field.',
},
'account.settings.field.username.help.text': {
id: 'account.settings.field.username.help.text',
defaultMessage: 'The name that identifies you on {siteName}. You cannot change your username.',
description: 'Help text for the account settings username field.',
},
'account.settings.field.full.name': {
id: 'account.settings.field.full.name',
defaultMessage: 'Full name',
description: 'Label for account settings name field.',
},
'account.settings.field.full.name.empty': {
id: 'account.settings.field.full.name.empty',
defaultMessage: 'Add name',
description: 'Placeholder for empty account settings name field.',
},
'account.settings.field.full.name.help.text': {
id: 'account.settings.field.full.name.help.text',
defaultMessage: 'The name that is used for ID verification and that appears on your certificates.',
description: 'Help text for the account settings name field.',
},
'account.settings.field.full.name.help.text.default': {
id: 'account.settings.field.full.name.help.text.default',
defaultMessage: 'The name that appears on your public profile.',
description: 'Help text for the account settings name field.',
},
'account.settings.field.full.name.help.text.default.certificate': {
id: 'account.settings.field.full.name.help.text.default.certificate',
defaultMessage: 'This name is selected to appear on your certificates and public-facing records.',
description: 'Help text for the account settings name field.',
},
'account.settings.field.name.verified': {
id: 'account.settings.field.name.verified',
defaultMessage: 'Verified name',
description: 'Label for account settings verified name field.',
},
'account.settings.field.name.verified.help.text.verified': {
id: 'account.settings.field.name.verified.help.text.verified',
defaultMessage: 'This name has been verified by photo ID.',
description: 'Help text for the account settings verified name field when the name is verified.',
},
'account.settings.field.name.verified.help.text.verified.proctored': {
id: 'account.settings.field.name.verified.help.text.verified.proctored',
defaultMessage: 'This name has been verified by proctoring.',
description: 'Help text for the account settings verified name field when the name is verified through proctoring.',
},
'account.settings.field.name.verified.help.text.verified.certificate': {
id: 'account.settings.field.name.verified.help.text.verified.certificate',
defaultMessage: 'This name has been verified by photo ID, and is selected to appear on your certificates and public-facing records.',
description: 'Help text for the account settings verified name field when the name is selected for certificates.',
},
'account.settings.field.name.verified.help.text.verified.proctored.certificate': {
id: 'account.settings.field.name.verified.help.text.verified.proctored.certificate',
defaultMessage: 'This name has been verified by proctoring, and is selected to appear on your certificates and public-facing records.',
description: 'Help text for the account settings verified name field when the name is selected for certificates, and the name is verified through proctoring.',
},
'account.settings.field.name.verified.help.text.submitted': {
id: 'account.settings.field.name.verified.help.text.submitted',
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Verified name cannot be changed at this time.',
description: 'Help text for the account settings verified name field when a verified name has been submitted.',
},
'account.settings.field.name.verified.help.text.submitted.proctored': {
id: 'account.settings.field.name.verified.help.text.submitted.proctored',
defaultMessage: 'Your proctored exam has been submitted. Verified name cannot be changed at this time. Please check back in 2-5 days.',
description: 'Help text for the account settings verified name field when a verified name has been submitted through proctoring.',
},
'account.settings.field.name.verified.help.text.submitted.certificate': {
id: 'account.settings.field.name.verified.help.text.submitted.certificate',
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Verified name cannot be changed at this time.',
description: 'Help text for the account settings verified name field when a verified name has been submitted and will appear on certificates.',
},
'account.settings.field.name.verified.help.text.submitted.proctored.certificate': {
id: 'account.settings.field.name.verified.help.text.submitted.proctored.certificate',
defaultMessage: 'Once your proctored exam passes review, this name will appear on your certificate and public-facing records. Verified Name cannot be changed at this time.',
description: 'Help text for the account settings verified name field when a verified name has been submitted through proctoring and will appear on certificates.',
},
'account.settings.field.full.name.help.text.submitted': {
id: 'account.settings.field.full.name.help.text.submitted',
defaultMessage: 'Verification has been submitted. This usually takes 48 hours or less. Full name cannot be changed at this time.',
description: 'Help text for the account settings full name field when a verified name has been submitted.',
},
'account.settings.field.full.name.help.text.submitted.proctored': {
id: 'account.settings.field.full.name.help.text.submitted.proctored',
defaultMessage: 'Your proctored exam has been submitted. Full name cannot be changed at this time. Please check back in 2-5 days.',
description: 'Help text for the account settings full name field when a verified name has been submitted through proctoring.',
},
'account.settings.field.full.name.help.text.submitted.certificate': {
id: 'account.settings.field.full.name.help.text.submitted.certificate',
defaultMessage: 'When identity verification is successful, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',
description: 'Help text for the account settings full name field when a full name has been submitted and will appear on certificates.',
},
'account.settings.field.full.name.help.text.submitted.proctored.certificate': {
id: 'account.settings.field.full.name.help.text.submitted.proctored.certificate',
defaultMessage: 'Once your proctored exam passes review, this name will appear on your certificates and public-facing records. Full name cannot be changed at this time.',
description: 'Help text for the account settings full name field when a full name has been submitted and will appear on certificates.',
},
'account.settings.field.name.verified.success.message': {
id: 'account.settings.field.name.verified.success.message',
defaultMessage: 'Your identity verification request has successfully completed. You now have the option of selecting which name you prefer to appear on your certificates and public-records.',
description: 'The body of the success alert indicating that a user\'s name has been verified',
},
'account.settings.field.name.verified.success.message.header': {
id: 'account.settings.field.name.verified.success.message.header',
defaultMessage: 'Your name change request is complete!',
description: 'The header of the success alert indicating that a user\'s name has been verified',
},
'account.settings.field.name.verified.failure.message': {
id: 'account.settings.field.name.verified.failure.message',
defaultMessage: 'Your most recent identity verification attempt did not pass. Related account settings have been restored.',
description: 'The body of the failure alert indicating that a user\'s name was not able to be verified',
},
'account.settings.field.name.verified.failure.message.header': {
id: 'account.settings.field.name.verified.failure.message.header',
defaultMessage: 'We were not able to verify your identity.',
description: 'The header of the failure alert indicating that a user\'s name was not able to be verified',
},
'account.settings.field.name.verified.failure.message.help.link': {
id: 'account.settings.field.name.verified.failure.message.help.link',
defaultMessage: 'Learn more about ID verification',
description: 'The text of the button displayed when a user\'s name was not able to be verified, intended to direct the user to a help article about ID verification.',
},
'account.settings.field.name.verified.submitted.message': {
id: 'account.settings.field.name.verified.submitted.message',
defaultMessage: 'Your identity verification request has been submitted and usually takes between 24 and 48 hours to complete.',
description: 'The body of the submitted alert indicating that a user\'s name has been submitted for verification',
},
'account.settings.field.name.verified.submitted.message.certificate': {
id: 'account.settings.field.name.verified.submitted.message.certificate',
defaultMessage: 'When your request is approved, your updated name will appear on all associated certificates and public-facing records.',
description: 'The body of the submitted alert indicating that a user\'s name will be updated on certificates.',
},
'account.settings.field.name.verified.submitted.message.header': {
id: 'account.settings.field.name.verified.submitted.message.header',
defaultMessage: 'Your name change request is almost complete!',
description: 'The header of the submitted alert indicating that a user\'s name has been submitted for verification',
},
'account.settings.field.email': {
id: 'account.settings.field.email',
defaultMessage: 'Email address (Sign in)',
description: 'Label for account settings email field.',
},
'account.settings.field.email.empty': {
id: 'account.settings.field.email.empty',
defaultMessage: 'Add email address',
description: 'Placeholder for empty account settings email field.',
},
'account.settings.field.email.confirmation': {
id: 'account.settings.field.email.confirmation',
defaultMessage: 'Weve sent a confirmation message to {value}. Click the link in the message to update your email address.',
description: 'Confirmation message for saving the account settings email field.',
},
'account.settings.field.email.help.text': {
id: 'account.settings.field.email.help.text',
defaultMessage: 'You receive messages from {siteName} and course teams at this address.',
description: 'Help text for the account settings email field.',
},
'account.settings.field.secondary.email': {
id: 'account.settings.field.secondary.email',
defaultMessage: 'Recovery email address',
description: 'Label for account settings recovery email field.',
},
'account.settings.field.secondary.email.empty': {
id: 'account.settings.field.secondary.email.empty',
defaultMessage: 'Add a recovery email address',
description: 'Placeholder for empty account settings recovery email field.',
},
'account.settings.field.secondary.email.confirmation': {
id: 'account.settings.field.secondary.email.confirmation',
defaultMessage: 'Weve sent a confirmation message to {value}. Click the link in the message to update your recovery email address.',
description: 'Confirmation message for saving the account settings recovery email field.',
},
'account.settings.email.field.confirmation.header': {
id: 'account.settings.email.field.confirmation.header',
defaultMessage: 'One more step!',
@@ -246,118 +56,19 @@ const messages = defineMessages({
defaultMessage: 'Year of birth',
description: 'Label for account settings year of birth field.',
},
'account.settings.field.dob.empty': {
id: 'account.settings.field.dob.empty',
defaultMessage: 'Add year of birth',
description: 'Placeholder for empty account settings year of birth field.',
},
'account.settings.field.year_of_birth.options.empty': {
id: 'account.settings.field.year_of_birth.options.empty',
defaultMessage: 'Select a year of birth',
description: 'Option for empty value on account settings year of birth field.',
},
'account.settings.field.dob.month': {
id: 'account.settings.field.dob.month',
defaultMessage: 'Month',
description: 'Label for account settings month of birth field.',
},
'account.settings.field.dob.year': {
id: 'account.settings.field.dob.year',
defaultMessage: 'Year',
description: 'Label for account settings year of birth field.',
},
'account.settings.field.dob.month.default': {
id: 'account.settings.field.month.year.default',
defaultMessage: 'Select month',
description: 'Default label for account settings month of birth field.',
},
'account.settings.field.dob.year.default': {
id: 'account.settings.field.dob.year.default',
defaultMessage: 'Select year',
description: 'Default label for account settings year of birth field.',
},
'account.settings.field.dob.form.button': {
id: 'account.settings.field.dob.form.button',
defaultMessage: 'Please confirm your date of birth',
description: 'Message to prompt user to enter dob',
},
'account.settings.field.dob.form.title': {
id: 'account.settings.field.dob.form.title',
defaultMessage: 'Enter your birth month and year',
description: 'Title of DOB form',
},
'account.settings.field.dob.form.help.text': {
id: 'account.settings.field.dob.form.help.text',
defaultMessage: 'We ask for birth month and year information to help us comply with our legal obligations.',
description: 'Help text for DOB form',
},
'account.settings.field.dob.form.success': {
id: 'account.settings.field.dob.form.success',
defaultMessage: 'Thank you for entering your information.',
description: 'Title of banner when date of birth is successfully entered',
},
'account.settings.field.month_of_birth.options.empty': {
id: 'account.settings.field.month_of_birth.options.empty',
defaultMessage: 'Select a month of birth',
description: 'Option for empty value on account settings month of birth field.',
},
'account.settingsfield.dob.error.general': {
id: 'account.settingsfield.dob.error.general',
defaultMessage: 'A technical error occurred. Please try again.',
description: 'Generic error message.',
},
'account.settings.field.country': {
id: 'account.settings.field.country',
defaultMessage: 'Country',
description: 'Label for account settings country field.',
},
'account.settings.field.country.empty': {
id: 'account.settings.field.country.empty',
defaultMessage: 'Add country',
description: 'Placeholder for empty account settings country field.',
},
'account.settings.field.country.options.empty': {
id: 'account.settings.field.country.options.empty',
defaultMessage: 'Select a Country',
description: 'Option for empty value on account settings country field.',
},
'account.settings.field.state': {
id: 'account.settings.field.state',
defaultMessage: 'State',
description: 'Label for account settings state field.',
},
'account.settings.field.state.empty': {
id: 'account.settings.field.state.empty',
defaultMessage: 'Add state',
description: 'Placeholder for empty account settings state field.',
},
'account.settings.field.state.options.empty': {
id: 'account.settings.field.state.options.empty',
defaultMessage: 'Select a State',
description: 'Option for empty value on account settings state field.',
},
'account.settings.field.site.language': {
id: 'account.settings.field.site.language',
defaultMessage: 'Site language',
description: 'Label for account settings site language field.',
},
'account.settings.field.site.language.help.text': {
id: 'account.settings.field.site.language.help.text',
defaultMessage: 'The language used throughout this site. This site is currently available in a limited number of languages.',
description: 'Help text for the site language field.',
},
'account.settings.field.education': {
id: 'account.settings.field.education',
defaultMessage: 'Education',
description: 'Label for account settings education field.',
},
'account.settings.field.education.empty': {
id: 'account.settings.field.education.empty',
defaultMessage: 'Add level of education',
description: 'Placeholder for empty account settings education field.',
},
'account.settings.field.education.levels.empty': {
id: 'account.settings.field.education.levels.empty',
'account.settings.field.education.levels.null': {
id: 'account.settings.field.education.levels.null',
defaultMessage: 'Select a level of education',
description: 'Placeholder for the education levels dropdown.',
},
@@ -401,8 +112,8 @@ const messages = defineMessages({
defaultMessage: 'No formal education',
description: 'Selected by the user to describe their education.',
},
'account.settings.field.education.levels.other': {
id: 'account.settings.field.education.levels.other',
'account.settings.field.education.levels.o': {
id: 'account.settings.field.education.levels.o',
defaultMessage: 'Other education',
description: 'Selected by the user if they have a type of education not described by the other choices.',
},
@@ -412,13 +123,8 @@ const messages = defineMessages({
defaultMessage: 'Gender',
description: 'Label for account settings gender field.',
},
'account.settings.field.gender.empty': {
id: 'account.settings.field.gender.empty',
defaultMessage: 'Add gender',
description: 'Placeholder for empty account settings gender field.',
},
'account.settings.field.gender.options.empty': {
id: 'account.settings.field.gender.options.empty',
'account.settings.field.gender.options.null': {
id: 'account.settings.field.gender.options.null',
defaultMessage: 'Select a gender',
description: 'Placeholder for the gender options dropdown.',
},
@@ -437,51 +143,11 @@ const messages = defineMessages({
defaultMessage: 'Other',
description: 'The label for catch-all gender option.',
},
'account.settings.field.language.proficiencies': {
id: 'account.settings.field.language.proficiencies',
defaultMessage: 'Spoken language',
description: 'Label for account settings spoken language field.',
},
'account.settings.field.language.proficiencies.empty': {
id: 'account.settings.field.language.proficiencies.empty',
defaultMessage: 'Add a spoken language',
description: 'Placeholder for empty account settings spoken language field.',
},
'account.settings.field.language_proficiencies.options.empty': {
id: 'account.settings.field.language_proficiencies.options.empty',
defaultMessage: 'Select a Language',
description: 'Option for an empty value on account settings spoken language field.',
},
'account.settings.field.time.zone': {
id: 'account.settings.field.time.zone',
defaultMessage: 'Time zone',
description: 'Label for time zone settings field.',
},
'account.settings.field.time.zone.empty': {
id: 'account.settings.field.time.zone.empty',
defaultMessage: 'Set time zone',
description: 'Placeholder for empty for time zone settings field.',
},
'account.settings.field.time.zone.description': {
id: 'account.settings.field.time.zone.description',
defaultMessage: 'Select the time zone for displaying course dates. If you do not specify a time zone, course dates, including assignment deadlines, will be displayed in your browsers local time zone.',
description: 'Description for time zone settings field.',
},
'account.settings.field.time.zone.default': {
id: 'account.settings.field.time.zone.default',
defaultMessage: 'Default (Local Time Zone)',
description: 'The default option for a time zone.',
},
'account.settings.field.time.zone.all': {
id: 'account.settings.field.time.zone.all',
defaultMessage: 'All time zones',
description: 'The label for the group of options for all time zones.',
},
'account.settings.field.time.zone.country': {
id: 'account.settings.field.time.zone.country',
defaultMessage: 'Country time zones',
description: 'The group of time zone options for a country.',
defaultMessage: 'Spoken Languages',
description: 'Label for account settings spoken languages field.',
},
'account.settings.section.social.media': {
@@ -491,7 +157,7 @@ const messages = defineMessages({
},
'account.settings.section.social.media.description': {
id: 'account.settings.section.social.media.description',
defaultMessage: 'Optionally, link your personal accounts to the social media icons on your {siteName} profile.',
defaultMessage: 'Optionally, link your personal accounts to the social media icons on your edX profile.',
description: 'Section subheader for social media links settings',
},
'account.settings.field.social.platform.name.linkedin': {
@@ -499,36 +165,21 @@ const messages = defineMessages({
defaultMessage: 'LinkedIn',
description: 'Label for LinkedIn',
},
'account.settings.field.social.platform.name.linkedin.empty': {
id: 'account.settings.field.social.platform.name.linkedin.empty',
defaultMessage: 'Add LinkedIn profile',
description: 'Placeholder for an empty LinkedIn field',
},
'account.settings.jump.nav.delete.account': {
id: 'account.settings.jump.nav.delete.account',
defaultMessage: 'Delete My Account',
description: 'Header for the user account deletion area',
},
'account.settings.field.social.platform.name.twitter': {
id: 'account.settings.field.social.platform.name.twitter',
defaultMessage: 'Twitter',
description: 'Label for Twitter',
},
'account.settings.field.social.platform.name.twitter.empty': {
id: 'account.settings.field.social.platform.name.twitter.empty',
defaultMessage: 'Add Twitter profile',
description: 'Placeholder for an empty Twitter field',
},
'account.settings.field.social.platform.name.facebook': {
id: 'account.settings.field.social.platform.name.facebook',
defaultMessage: 'Facebook',
description: 'Label for Facebook',
},
'account.settings.field.social.platform.name.facebook.empty': {
id: 'account.settings.field.social.platform.name.facebook.empty',
defaultMessage: 'Add Facebook profile',
description: 'Placeholder for an empty Facebook field',
'account.settings.editable.field.password.reset.button': {
id: 'account.settings.editable.field.password.reset.button',
defaultMessage: 'Reset Password',
description: 'The password reset button in account settings',
},
'account.settings.editable.field.action.save': {
id: 'account.settings.editable.field.action.save',
@@ -545,36 +196,6 @@ const messages = defineMessages({
defaultMessage: 'Edit',
description: 'The edit button on an editable field',
},
'account.settings.static.field.empty': {
id: 'account.settings.static.field.empty',
defaultMessage: 'No value set. Contact your {enterprise} administrator to make changes.',
description: 'The placeholder for an empty but uneditable field',
},
'account.settings.static.field.empty.no.admin': {
id: 'account.settings.static.field.empty.no.admin',
defaultMessage: 'No value set.',
description: 'The placeholder for an empty but uneditable field when there is no administrator',
},
'notification.preferences.notifications.label': {
id: 'notification.preferences.notifications.label',
defaultMessage: 'Notifications',
description: 'Label for Notifications',
},
'account.settings.field.work.experience': {
id: 'account.settings.work.experience',
defaultMessage: 'Work Experience',
description: 'Label for account settings Work experience field.',
},
'account.settings.field.work.experience.empty': {
id: 'account.settings.field.work.experience.empty',
defaultMessage: 'Add work experience',
description: 'Placeholder for empty account settings work experience field.',
},
'account.settings.field.work.experience.options.empty': {
id: 'account.settings.field.work.experience.options.empty',
defaultMessage: 'Select work experience',
description: 'Placeholder for the work experience levels dropdown.',
},
});
export default messages;

View File

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

View File

@@ -1,162 +0,0 @@
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Form, StatefulButton, ModalDialog, ActionRow, useToggle, Button,
} from '@openedx/paragon';
import React, { useCallback, useEffect, useState } from 'react';
import { connect, useDispatch } from 'react-redux';
import messages from './AccountSettingsPage.messages';
import { YEAR_OF_BIRTH_OPTIONS } from './data/constants';
import { editableFieldSelector } from './data/selectors';
import { saveSettingsReset } from './data/actions';
const DOBModal = (props) => {
const {
saveState,
error,
onSubmit,
intl,
} = props;
const dispatch = useDispatch();
// eslint-disable-next-line no-unused-vars
const [isOpen, open, close, toggle] = useToggle(true, {});
const [monthValue, setMonthValue] = useState('');
const [yearValue, setYearValue] = useState('');
const handleChange = (e) => {
e.preventDefault();
if (e.target.name === 'month') {
setMonthValue(e.target.value);
} else if (e.target.name === 'year') {
setYearValue(e.target.value);
}
};
const handleSubmit = (e) => {
e.preventDefault();
const data = monthValue !== '' && yearValue !== '' ? [{ field_name: 'DOB', field_value: `${yearValue}-${monthValue}` }] : [];
onSubmit('extended_profile', data);
};
const handleComplete = useCallback(() => {
localStorage.setItem('submittedDOB', 'true');
close();
dispatch(saveSettingsReset());
}, [dispatch, close]);
const handleClose = useCallback(() => {
close();
dispatch(saveSettingsReset());
}, [dispatch, close]);
function renderErrors() {
if (saveState === 'error' || error) {
return (
<Form.Control.Feedback type="invalid" key="general-error">
{intl.formatMessage(messages['account.settingsfield.dob.error.general'])}
</Form.Control.Feedback>
);
}
return null;
}
useEffect(() => {
if (saveState === 'complete' && isOpen) {
handleComplete();
}
}, [handleComplete, saveState, isOpen, monthValue, yearValue]);
return (
<>
<Button variant="primary" onClick={open}>
{intl.formatMessage(messages['account.settings.field.dob.form.button'])}
</Button>
<ModalDialog
title={intl.formatMessage(messages['account.settings.field.dob.form.title'])}
isOpen={isOpen}
onClose={handleClose}
hasCloseButton={false}
variant="default"
>
<form onSubmit={handleSubmit}>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages['account.settings.field.dob.form.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="overflow-hidden" style={{ padding: '1.5rem' }}>
<p>{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
<Form.Group>
<Form.Label>
{intl.formatMessage(messages['account.settings.field.dob.month'])}
</Form.Label>
<Form.Control
as="select"
name="month"
onChange={handleChange}
>
<option value="">{intl.formatMessage(messages['account.settings.field.dob.month.default'])}</option>
{[...Array(12).keys()].map(month => (
<option key={month + 1} value={month + 1}>{month + 1}</option>
))}
</Form.Control>
</Form.Group>
<Form.Group>
<Form.Label>
{intl.formatMessage(messages['account.settings.field.dob.year'])}
</Form.Label>
<Form.Control
as="select"
name="year"
onChange={handleChange}
>
<option value="">{intl.formatMessage(messages['account.settings.field.dob.year.default'])}</option>
{YEAR_OF_BIRTH_OPTIONS.map(year => (
<option key={year.value} value={year.value}>{year.label}</option>
))}
</Form.Control>
</Form.Group>
{renderErrors()}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
Cancel
</ModalDialog.CloseButton>
<StatefulButton
type="submit"
state={!(monthValue && yearValue) ? 'unedited' : saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
disabledStates={['unedited']}
/>
</ActionRow>
</ModalDialog.Footer>
</form>
</ModalDialog>
</>
);
};
DOBModal.propTypes = {
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
error: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
intl: intlShape.isRequired,
};
DOBModal.defaultProps = {
saveState: undefined,
error: undefined,
};
export default connect(editableFieldSelector)(injectIntl(DOBModal));

View File

@@ -1,212 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Form, StatefulButton,
} from '@openedx/paragon';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
import CertificatePreference from './certificate-preference/CertificatePreference';
const EditableField = (props) => {
const {
name,
label,
emptyLabel,
type,
value,
userSuppliedValue,
saveState,
error,
confirmationMessageDefinition,
confirmationValue,
helpText,
onEdit,
onCancel,
onSubmit,
onChange,
isEditing,
isEditable,
isGrayedOut,
intl,
...others
} = props;
const id = `field-${name}`;
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(name, new FormData(e.target).get(name));
};
const handleChange = (e) => {
onChange(name, e.target.value);
};
const handleEdit = () => {
onEdit(name);
};
const handleCancel = () => {
onCancel(name);
};
const renderEmptyLabel = () => {
if (isEditable) {
return <Button variant="link" onClick={handleEdit} className="p-0">{emptyLabel}</Button>;
}
return <span className="text-muted">{emptyLabel}</span>;
};
const renderValue = (rawValue) => {
if (!rawValue) {
return renderEmptyLabel();
}
let finalValue = rawValue;
if (userSuppliedValue) {
finalValue += `: ${userSuppliedValue}`;
}
return finalValue;
};
const renderConfirmationMessage = () => {
if (!confirmationMessageDefinition || !confirmationValue) {
return null;
}
return intl.formatMessage(confirmationMessageDefinition, {
value: confirmationValue,
});
};
return (
<SwitchContent
expression={isEditing ? 'editing' : 'default'}
cases={{
editing: (
<>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={id}
isInvalid={error != null}
>
<Form.Label size="sm" className="h6 d-block" htmlFor={id}>{label}</Form.Label>
<Form.Control
data-hj-suppress
name={name}
id={id}
type={type}
value={value}
onChange={handleChange}
{...others}
/>
{!!helpText && <Form.Text>{helpText}</Form.Text>}
{error != null && <Form.Control.Feedback hasIcon={false}>{error}</Form.Control.Feedback>}
{others.children}
</Form.Group>
<p>
<StatefulButton
type="submit"
className="mr-2"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
onClick={(e) => {
// Swallow clicks if the state is pending.
// We do this instead of disabling the button to prevent
// it from losing focus (disabled elements cannot have focus).
// Disabling it would causes upstream issues in focus management.
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (saveState === 'pending') { e.preventDefault(); }
}}
disabledStates={[]}
/>
<Button
variant="outline-primary"
onClick={handleCancel}
>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
</>
),
default: (
<div className="form-group">
<div className="d-flex align-items-start">
<h6 aria-level="3">{label}</h6>
{isEditable ? (
<Button variant="link" onClick={handleEdit} className="ml-3">
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
</Button>
) : null}
</div>
<p data-hj-suppress className={classNames('text-truncate', { 'grayed-out': isGrayedOut })}>{renderValue(value)}</p>
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
</div>
),
}}
/>
);
};
EditableField.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]),
emptyLabel: PropTypes.node,
type: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
userSuppliedValue: PropTypes.string,
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
error: PropTypes.string,
confirmationMessageDefinition: PropTypes.shape({
id: PropTypes.string.isRequired,
defaultMessage: PropTypes.string.isRequired,
description: PropTypes.string,
}),
confirmationValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
helpText: PropTypes.node,
onEdit: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
isGrayedOut: PropTypes.bool,
intl: intlShape.isRequired,
};
EditableField.defaultProps = {
value: undefined,
saveState: undefined,
label: undefined,
emptyLabel: undefined,
error: undefined,
confirmationMessageDefinition: undefined,
confirmationValue: undefined,
helpText: undefined,
isEditing: false,
isEditable: true,
isGrayedOut: false,
userSuppliedValue: undefined,
};
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(injectIntl(EditableField));

View File

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

View File

@@ -1,78 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import classNames from 'classnames';
import React from 'react';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import messages from './AccountSettingsPage.messages';
const JumpNav = ({
intl,
}) => {
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
return (
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
<Scrollspy
items={[
'basic-information',
'profile-information',
'social-media',
'notifications',
'site-preferences',
'linked-accounts',
'delete-account',
]}
className="list-unstyled"
currentClassName="font-weight-bold"
offset={-64}
>
<li>
<NavHashLink to="#basic-information">
{intl.formatMessage(messages['account.settings.section.account.information'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#profile-information">
{intl.formatMessage(messages['account.settings.section.profile.information'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#social-media">
{intl.formatMessage(messages['account.settings.section.social.media'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#notifications">
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#site-preferences">
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#linked-accounts">
{intl.formatMessage(messages['account.settings.section.linked.accounts'])}
</NavHashLink>
</li>
{getConfig().ENABLE_ACCOUNT_DELETION
&& (
<li>
<NavHashLink to="#delete-account">
{intl.formatMessage(messages['account.settings.jump.nav.delete.account'])}
</NavHashLink>
</li>
)}
</Scrollspy>
</div>
);
};
JumpNav.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(JumpNav);

View File

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

View File

@@ -1,45 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
const OneTimeDismissibleAlert = (props) => {
const [dismissed, setDismissed] = useState(localStorage.getItem(props.id) !== 'true');
const onClose = () => {
localStorage.setItem(props.id, 'true');
setDismissed(false);
};
return (
<Alert
variant={props.variant}
dismissible
icon={props.icon}
onClose={onClose}
show={dismissed}
>
<Alert.Heading>{props.header}</Alert.Heading>
<p>
{props.body}
</p>
</Alert>
);
};
OneTimeDismissibleAlert.propTypes = {
id: PropTypes.string.isRequired,
variant: PropTypes.string,
icon: PropTypes.func,
header: PropTypes.string,
body: PropTypes.string,
};
OneTimeDismissibleAlert.defaultProps = {
variant: 'success',
icon: undefined,
header: undefined,
body: undefined,
};
export default OneTimeDismissibleAlert;

View File

@@ -2,45 +2,13 @@
.form-group {
margin-bottom: 1.5rem;
}
h6, .h6 {
margin-bottom: .25rem;
}
.btn-link {
line-height: 1.2;
border: none;
padding: 0;
display: inline-block;
}
.jump-nav-sm {
top: 1rem;
}
.jump-nav {
li {
margin-bottom: .5rem;
a {
text-decoration: underline;
}
}
}
.custom-switch {
padding: 0;
max-width: 500px;
.custom-control-label {
left: 2.25rem;
line-height: 1.6rem;
}
}
.grayed-out{
opacity: 0.6; /* Real browsers */
filter: alpha(opacity = 60); /* MSIE */
}
}

View File

@@ -0,0 +1,122 @@
import { utils } from '../common';
const { AsyncActionType } = utils;
export const FETCH_ACCOUNT = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_ACCOUNT');
export const SAVE_ACCOUNT = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_ACCOUNT');
export const FETCH_THIRD_PARTY_AUTH_PROVIDERS = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_THIRD_PARTY_AUTH_PROVIDERS');
export const RESET_PASSWORD = new AsyncActionType('ACCOUNT_SETTINGS', 'RESET_PASSWORD');
export const OPEN_FORM = 'OPEN_FORM';
export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
// FETCH EXAMPLE ACTIONS
export const fetchAccount = () => ({
type: FETCH_ACCOUNT.BASE,
});
export const fetchAccountBegin = () => ({
type: FETCH_ACCOUNT.BEGIN,
});
export const fetchAccountSuccess = values => ({
type: FETCH_ACCOUNT.SUCCESS,
payload: { values },
});
export const fetchAccountFailure = error => ({
type: FETCH_ACCOUNT.FAILURE,
payload: { error },
});
export const fetchAccountReset = () => ({
type: FETCH_ACCOUNT.RESET,
});
export const openForm = formId => ({
type: OPEN_FORM,
payload: { formId },
});
export const closeForm = formId => ({
type: CLOSE_FORM,
payload: { formId },
});
// FORM STATE ACTIONS
export const updateDraft = (name, value) => ({
type: UPDATE_DRAFT,
payload: {
name,
value,
},
});
export const resetDrafts = () => ({
type: RESET_DRAFTS,
});
// SAVE PROFILE ACTIONS
export const saveAccount = (formId, commitValues) => ({
type: SAVE_ACCOUNT.BASE,
payload: { formId, commitValues },
});
export const saveAccountBegin = () => ({
type: SAVE_ACCOUNT.BEGIN,
});
export const saveAccountSuccess = (values, confirmationValues) => ({
type: SAVE_ACCOUNT.SUCCESS,
payload: { values, confirmationValues },
});
export const saveAccountReset = () => ({
type: SAVE_ACCOUNT.RESET,
});
export const saveAccountFailure = ({ fieldErrors, message }) => ({
type: SAVE_ACCOUNT.FAILURE,
payload: { errors: fieldErrors, message },
});
// SAVE PROFILE ACTIONS
export const resetPassword = () => ({
type: RESET_PASSWORD.BASE,
});
export const resetPasswordBegin = () => ({
type: RESET_PASSWORD.BEGIN,
});
export const resetPasswordSuccess = () => ({
type: RESET_PASSWORD.SUCCESS,
});
export const resetPasswordReset = () => ({
type: RESET_PASSWORD.RESET,
});
// fetch third party auth providers
export const fetchThirdPartyAuthProviders = () => ({
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.BASE,
});
export const fetchThirdPartyAuthProvidersBegin = () => ({
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.BEGIN,
});
export const fetchThirdPartyAuthProvidersSuccess = providers => ({
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.SUCCESS, payload: { providers },
});
export const fetchThirdPartyAuthProvidersFailure = error => ({
type: FETCH_THIRD_PARTY_AUTH_PROVIDERS.FAILURE, payload: { error },
});

View File

@@ -1,173 +0,0 @@
import React, { useState, useEffect } from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
import {
ActionRow,
Form,
ModalDialog,
StatefulButton,
} from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
closeForm,
resetDrafts,
saveSettings,
updateDraft,
} from '../data/actions';
import { certPreferenceSelector } from '../data/selectors';
import commonMessages from '../AccountSettingsPage.messages';
import messages from './messages';
const CertificatePreference = ({
intl,
fieldName,
originalFullName,
originalVerifiedName,
saveState,
useVerifiedNameForCerts,
}) => {
const dispatch = useDispatch();
const [checked, setChecked] = useState(false);
const [modalIsOpen, setModalIsOpen] = useState(false);
const formId = 'useVerifiedNameForCerts';
const handleCheckboxChange = () => {
if (!checked) {
if (fieldName === 'verified_name') {
dispatch(updateDraft(formId, true));
} else {
dispatch(updateDraft(formId, false));
}
} else {
setModalIsOpen(true);
}
};
const handleCancel = () => {
setModalIsOpen(false);
dispatch(resetDrafts());
};
const handleModalChange = (e) => {
if (e.target.value === 'fullName') {
dispatch(updateDraft(formId, false));
} else {
dispatch(updateDraft(formId, true));
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (saveState === 'pending') {
return;
}
dispatch(saveSettings(formId, useVerifiedNameForCerts));
};
useEffect(() => {
if (originalVerifiedName) {
if (fieldName === 'verified_name') {
setChecked(useVerifiedNameForCerts);
} else {
setChecked(!useVerifiedNameForCerts);
}
}
}, [originalVerifiedName, fieldName, useVerifiedNameForCerts]);
useEffect(() => {
if (originalVerifiedName) {
if (modalIsOpen && saveState === 'complete') {
setModalIsOpen(false);
dispatch(closeForm(fieldName));
}
}
}, [dispatch, originalVerifiedName, fieldName, modalIsOpen, saveState]);
// If the user doesn't have an approved verified name, do not display this component
return originalVerifiedName ? (
<>
<Form.Checkbox className="mt-1 mb-4" checked={checked} onChange={handleCheckboxChange}>
{intl.formatMessage(messages['account.settings.field.name.checkbox.certificate.select'])}
</Form.Checkbox>
<ModalDialog
title={intl.formatMessage(messages['account.settings.field.name.modal.certificate.title'])}
isOpen={modalIsOpen}
onClose={handleCancel}
size="lg"
hasCloseButton
isFullscreenOnMobile
>
<Form onSubmit={handleSubmit}>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages['account.settings.field.name.modal.certificate.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="overflow-hidden">
<Form.Group className="mb-4">
<Form.Label>
{intl.formatMessage(messages['account.settings.field.name.modal.certificate.select'])}
</Form.Label>
<Form.RadioSet
name={formId}
value={useVerifiedNameForCerts ? 'verifiedName' : 'fullName'}
onChange={handleModalChange}
>
<Form.Radio value="fullName">
{originalFullName}{' '}
({intl.formatMessage(messages['account.settings.field.name.modal.certificate.option.full'])})
</Form.Radio>
<Form.Radio value="verifiedName">
{originalVerifiedName}{' '}
({intl.formatMessage(messages['account.settings.field.name.modal.certificate.option.verified'])})
</Form.Radio>
</Form.RadioSet>
</Form.Group>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="outline-primary" disabled={saveState === 'pending'}>
{intl.formatMessage(commonMessages['account.settings.editable.field.action.cancel'])}
</ModalDialog.CloseButton>
<StatefulButton
type="submit"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.field.name.modal.certificate.button.choose']),
}}
disabledStates={[]}
/>
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
</>
) : null;
};
CertificatePreference.propTypes = {
intl: intlShape.isRequired,
fieldName: PropTypes.string.isRequired,
originalFullName: PropTypes.string,
originalVerifiedName: PropTypes.string,
saveState: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
};
CertificatePreference.defaultProps = {
originalFullName: '',
originalVerifiedName: '',
saveState: null,
useVerifiedNameForCerts: false,
};
export default connect(certPreferenceSelector)(injectIntl(CertificatePreference));

View File

@@ -1,22 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { handleRequestError } from '../../data/utils';
// eslint-disable-next-line import/prefer-default-export
export async function postVerifiedNameConfig(username, commitValues) {
const requestConfig = { headers: { Accept: 'application/json' } };
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name/config`;
const { useVerifiedNameForCerts } = commitValues;
const postValues = {
username,
use_verified_name_for_certs: useVerifiedNameForCerts,
};
const { data } = await getAuthenticatedHttpClient()
.post(requestUrl, postValues, requestConfig)
.catch(error => handleRequestError(error));
return data;
}

View File

@@ -1,36 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.field.name.checkbox.certificate.select': {
id: 'account.settings.field.name.certificate.select',
defaultMessage: 'If checked, this name will appear on your certificates and public-facing records.',
description: 'Label for checkbox describing that the selected name will appear on the users certificates.',
},
'account.settings.field.name.modal.certificate.title': {
id: 'account.settings.field.name.modal.certificate.title',
defaultMessage: 'Choose a preferred name for certificates and public-facing records',
description: 'Title instructing the user to choose a preferred name.',
},
'account.settings.field.name.modal.certificate.select': {
id: 'account.settings.field.name.modal.certificate.select',
defaultMessage: 'Select a name',
description: 'Label instructing the user to select a name.',
},
'account.settings.field.name.modal.certificate.option.full': {
id: 'account.settings.field.name.modal.certificate.option.full',
defaultMessage: 'Full Name',
description: 'Option representing the users full name.',
},
'account.settings.field.name.modal.certificate.option.verified': {
id: 'account.settings.field.name.modal.certificate.option.verified',
defaultMessage: 'Verified Name',
description: 'Option representing the users verified name.',
},
'account.settings.field.name.modal.certificate.button.choose': {
id: 'account.settings.field.name.modal.certificate.button.choose',
defaultMessage: 'Choose name',
description: 'Button to confirm the users name choice.',
},
});
export default messages;

View File

@@ -1,173 +0,0 @@
/* eslint-disable no-import-assign */
import React from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import {
fireEvent,
render,
screen,
} from '@testing-library/react';
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn(node => node), // Mock portal behavior
}));
import CertificatePreference from '../CertificatePreference'; // eslint-disable-line import/first
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
const IntlCertificatePreference = injectIntl(CertificatePreference);
const mockStore = configureStore();
describe('NameChange', () => {
let props = {};
let store = {};
const formId = 'useVerifiedNameForCerts';
const updateDraft = 'UPDATE_DRAFT';
const labelText = 'If checked, this name will appear on your certificates and public-facing records.';
const reduxWrapper = children => (
<Router>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
</Router>
);
beforeEach(() => {
store = mockStore();
props = {
fieldName: 'name',
originalFullName: 'Ed X',
originalVerifiedName: 'edX Verified',
saveState: null,
useVerifiedNameForCerts: false,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
patch: async () => ({
data: { status: 200 },
catch: () => {},
}),
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3 }));
});
afterEach(() => jest.clearAllMocks());
it('does not render if there is no verified name', () => {
props = {
...props,
originalVerifiedName: '',
};
const wrapper = render(reduxWrapper(<IntlCertificatePreference {...props} />));
expect(wrapper).toMatchSnapshot();
});
it('does not trigger modal when checking empty checkbox, and updates draft immediately', () => {
props = {
...props,
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(false);
fireEvent.click(checkbox);
expect(screen.queryByRole('radiogroup')).toBeNull();
expect(mockDispatch).toHaveBeenCalledWith({
payload: { name: formId, value: false },
type: updateDraft,
});
});
it('triggers modal when attempting to uncheck checkbox', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);
fireEvent.click(checkbox);
expect(mockDispatch).not.toHaveBeenCalled();
screen.getByRole('radiogroup');
});
it('updates draft when changing radio value', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
const fullNameOption = screen.getByLabelText('Ed X (Full Name)');
const verifiedNameOption = screen.getByLabelText('edX Verified (Verified Name)');
expect(fullNameOption.checked).toEqual(true);
expect(verifiedNameOption.checked).toEqual(false);
fireEvent.click(verifiedNameOption);
expect(mockDispatch).toHaveBeenCalledWith({
payload: { name: formId, value: true },
type: updateDraft,
});
});
it('clears draft on cancel', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
expect(mockDispatch).toHaveBeenCalledWith({ type: 'RESET_DRAFTS' });
expect(screen.queryByRole('radiogroup')).toBeNull();
});
it('submits', () => {
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
const submitButton = screen.getByText('Choose name');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith({
payload: { formId, commitValues: false, extendedProfile: {} },
type: 'ACCOUNT_SETTINGS__SAVE_SETTINGS',
});
});
it('checks box for verified name', () => {
props = {
...props,
fieldName: 'verified_name',
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);
});
});

View File

@@ -1,62 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`NameChange does not render if there is no verified name 1`] = `
{
"asFragment": [Function],
"baseElement": <body>
<div />
</body>,
"container": <div />,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"rerender": [Function],
"unmount": [Function],
}
`;

View File

@@ -2,16 +2,20 @@ import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
const Alert = (props) => (
<div className={classNames('alert d-flex align-items-start', props.className)}>
<div>
{props.icon}
function Alert(props) {
return (
<div className={classNames('alert d-flex align-items-start', props.className)}>
<div>
{props.icon}
</div>
<div>
{props.children}
</div>
</div>
<div>
{props.children}
</div>
</div>
);
);
}
Alert.propTypes = {
className: PropTypes.string,
@@ -25,4 +29,5 @@ Alert.defaultProps = {
children: undefined,
};
export default Alert;

View File

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

View File

@@ -1,28 +1,31 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Button, StatefulButton, Form,
} from '@openedx/paragon';
import { FormattedMessage } from 'react-intl';
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
import { Button, StatefulButton } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import Input from './temp/Input';
import ValidationFormGroup from './temp/ValidationFormGroup';
import SwitchContent from './temp/SwitchContent';
import Alert from './Alert';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import messages from '../AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
updateDraft,
saveAccount,
} from '../actions';
import { editableFieldSelector } from '../selectors';
const EmailField = (props) => {
function EmailField(props) {
const {
name,
label,
emptyLabel,
value,
saveState,
error,
@@ -57,15 +60,13 @@ const EmailField = (props) => {
};
const renderConfirmationMessage = () => {
if (!confirmationMessageDefinition || !confirmationValue) {
return null;
}
if (!confirmationMessageDefinition || !confirmationValue) return null;
return (
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2 h6" icon={faExclamationTriangle} />}
>
<h6 aria-level="3">
<h6>
{intl.formatMessage(messages['account.settings.email.field.confirmation.header'])}
</h6>
{intl.formatMessage(confirmationMessageDefinition, { value: confirmationValue })}
@@ -86,46 +87,31 @@ const EmailField = (props) => {
</span>
);
const renderEmptyLabel = () => {
if (isEditable) {
return <Button variant="link" onClick={handleEdit} className="p-0">{emptyLabel}</Button>;
}
return <span className="text-muted">{emptyLabel}</span>;
};
const renderValue = () => {
if (confirmationValue) {
return renderConfirmationValue();
}
return value || renderEmptyLabel();
};
return (
<SwitchContent
expression={isEditing ? 'editing' : 'default'}
cases={{
editing: (
<form onSubmit={handleSubmit}>
<Form.Group
controlId={id}
isInvalid={error != null}
<ValidationFormGroup
for={id}
invalid={error != null}
invalidMessage={error}
helpText={helpText}
>
<Form.Label className="h6 d-block" htmlFor={id}>{label}</Form.Label>
<Form.Control
data-hj-suppress
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
name={name}
id={id}
type="email"
value={value}
onChange={handleChange}
/>
{!!helpText && <Form.Text>{helpText}</Form.Text>}
{error != null && <Form.Control.Feedback hasIcon={false}>{error}</Form.Control.Feedback>}
</Form.Group>
</ValidationFormGroup>
<p>
<StatefulButton
type="submit"
className="mr-2"
className="btn-primary mr-2"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
@@ -138,13 +124,13 @@ const EmailField = (props) => {
// Swallowing the onSubmit event on the form would be better, but
// we would have to add that logic for every field given our
// current structure of the application.
if (saveState === 'pending') { e.preventDefault(); }
if (saveState === 'pending') e.preventDefault();
}}
disabledStates={[]}
/>
<Button
variant="outline-primary"
onClick={handleCancel}
className="btn-outline-primary"
>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
@@ -153,28 +139,27 @@ const EmailField = (props) => {
),
default: (
<div className="form-group">
<div className="d-flex align-items-start">
<h6 aria-level="3">{label}</h6>
<div className="d-flex justify-content-between align-items-start">
<h6>{label}</h6>
{isEditable ? (
<Button variant="link" onClick={handleEdit} className="ml-3">
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
<Button onClick={handleEdit} className="btn-link">
{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
</Button>
) : null}
</div>
<p data-hj-suppress>{renderValue()}</p>
<p>{confirmationValue ? renderConfirmationValue() : value}</p>
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
</div>
),
}}
/>
);
};
}
EmailField.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
emptyLabel: PropTypes.node,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
error: PropTypes.string,
@@ -198,7 +183,6 @@ EmailField.defaultProps = {
value: undefined,
saveState: undefined,
label: undefined,
emptyLabel: undefined,
error: undefined,
confirmationMessageDefinition: undefined,
confirmationValue: undefined,
@@ -207,7 +191,10 @@ EmailField.defaultProps = {
isEditable: true,
};
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
onChange: updateDraft,
onSubmit: saveAccount,
})(injectIntl(EmailField));

View File

@@ -0,0 +1,84 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
import { FormattedMessage } from 'react-intl';
import { StatefulButton, Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { resetPassword } from '../actions';
import { resetPasswordSelector } from '../selectors';
import messages from '../AccountSettingsPage.messages';
import Alert from './Alert';
function PasswordReset({ email, intl, ...props }) {
const renderConfirmationMessage = () => (
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<FormattedMessage
id="account.settings.editable.field.password.reset.button"
defaultMessage="We've sent a message to {email}. Click the link in the message to reset your password. Didn't receive the message? Contact {technicalSupportLink}."
description="The password reset button in account settings"
values={{
email,
technicalSupportLink: (
<Hyperlink
destination="https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-"
>
<FormattedMessage
id="account.settings.editable.field.password.reset.button.support.link"
defaultMessage="technical support"
description="link text used in message: account.settings.editable.field.password.reset.button 'Contact technical support.'"
/>
</Hyperlink>
),
}}
/>
</Alert>
);
return (
<div className="form-group">
<h6>
<FormattedMessage
id="account.settings.editable.field.password.reset.label"
defaultMessage="Password"
description="The password label in account settings"
/>
</h6>
<p>
<StatefulButton
className="btn-link"
state={props.resetPasswordState}
onClick={props.resetPassword}
disabledStates={[]}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.password.reset.button']),
}}
/>
</p>
{props.resetPasswordState === 'complete' ? renderConfirmationMessage() : null}
</div>
);
}
PasswordReset.propTypes = {
resetPassword: PropTypes.func.isRequired,
email: PropTypes.string,
resetPasswordState: PropTypes.string,
intl: intlShape.isRequired,
};
PasswordReset.defaultProps = {
email: '',
resetPasswordState: undefined,
};
export default connect(resetPasswordSelector, {
resetPassword,
})(injectIntl(PasswordReset));

View File

@@ -0,0 +1,151 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from 'react-intl';
import { Hyperlink } from '@edx/paragon';
import { fetchThirdPartyAuthProviders } from '../actions';
import { thirdPartyAuthSelector } from '../selectors';
class ThirdPartyAuth extends React.Component {
componentDidMount() {
this.props.fetchThirdPartyAuthProviders();
}
renderConnectedProvider(url, name) {
return (
<React.Fragment>
<h6>{name}</h6>
<Hyperlink destination={url} className="btn btn-outline-primary">
<FormattedMessage
id="account.settings.sso.link.account"
defaultMessage="Sign in with {name}"
description="An action link to link a connected third party account.m {name} will be Google, Facebook, etc."
values={{ name }}
/>
</Hyperlink>
</React.Fragment>
);
}
renderUnconnectedProvider(url, name) {
return (
<React.Fragment>
<h6>
{name}
<span className="small font-weight-normal text-muted ml-2">
<FormattedMessage
id="account.settings.sso.account.connected"
defaultMessage="Linked"
description="A badge to show that a third party account is linked"
/>
</span>
</h6>
<Hyperlink destination={url}>
<FormattedMessage
id="account.settings.sso.unlink.account"
defaultMessage="Unlink {name} account"
description="An action link to unlink a connected third party account"
values={{ name }}
/>
</Hyperlink>
</React.Fragment>
);
}
renderProvider({
name, disconnectUrl, connectUrl, connected, id,
}) {
return (
<div className="form-group" key={id}>
{
connected ?
this.renderUnconnectedProvider(disconnectUrl, name) :
this.renderConnectedProvider(connectUrl, name)
}
</div>
);
}
renderNoProviders() {
return (
<FormattedMessage
id="account.settings.sso.no.providers"
defaultMessage="No accounts can be linked at this time."
description="Displayed when no third party accounts are available to link an edX account to"
/>
);
}
renderLoading() {
return (
<FormattedMessage
id="account.settings.sso.loading"
defaultMessage="Loading..."
description="Waiting for data to load in the third party auth provider list"
/>
);
}
renderLoadingError() {
return (
<FormattedMessage
id="account.settings.sso.loading.error"
defaultMessage="There was a problem loading linked accounts."
description="Error message for failing to load the third party auth provider list"
/>
);
}
render() {
return (
<div>
<h2>
<FormattedMessage
id="account.settings.sso.section.header"
defaultMessage="Linked Accounts"
description="Section header for the third party auth settings"
/>
</h2>
<p>
<FormattedMessage
id="account.settings.sso.section.subheader"
defaultMessage="You can link your identity accounts to simplify signing in to edX."
description="Section subheader for the third party auth settings"
/>
</p>
{this.props.providers.map(this.renderProvider, this)}
{this.props.loaded && this.props.providers.length === 0 ? this.renderNoProviders() : null}
{this.props.loading ? this.renderLoading() : null}
{this.props.loadingError ? this.renderLoadingError() : null}
</div>
);
}
}
ThirdPartyAuth.propTypes = {
fetchThirdPartyAuthProviders: PropTypes.func.isRequired,
providers: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
disconnectUrl: PropTypes.string,
connectUrl: PropTypes.string,
connected: PropTypes.bool,
id: PropTypes.string,
})),
loading: PropTypes.bool,
loaded: PropTypes.bool,
loadingError: PropTypes.string,
};
ThirdPartyAuth.defaultProps = {
providers: [],
loading: false,
loaded: false,
loadingError: undefined,
};
export default connect(thirdPartyAuthSelector, {
fetchThirdPartyAuthProviders,
})(ThirdPartyAuth);

View File

@@ -0,0 +1,106 @@
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
class Input extends React.Component {
componentDidMount() {
if (process.env.NODE_ENV === 'development') {
this.checkHasLabel();
}
}
getHTMLTagForType() {
const { type } = this.props;
if (type === 'select' || type === 'textarea') return type;
return 'input';
}
getClassNameForType() {
switch (this.props.type) {
case 'file':
return 'form-control-file';
case 'checkbox':
case 'radio':
return 'form-check-input';
default:
return 'form-control';
}
}
getRef(forwardedRef) {
if (process.env.NODE_ENV !== 'development') return forwardedRef;
if (forwardedRef) return forwardedRef;
if (!this.innerRef) this.innerRef = React.createRef();
return this.innerRef;
}
checkHasLabel() {
const htmlNode = this.getRef().current;
if (htmlNode.labels.length > 0) return;
if (htmlNode.getAttribute('aria-label') !== null) return;
// eslint-disable-next-line no-console
if (console) console.warn('Input[a11y]: There is no associated label for this Input');
}
renderOptions(options) {
return options.map((option) => {
const {
value, label, group, ...attributes
} = option;
if (group) {
return (
<optgroup key={`optgroup-${label}`} label={label} {...attributes}>
{this.renderOptions(group)}
</optgroup>
);
}
return <option key={value} value={value} {...attributes}>{label}</option>;
}, this);
}
render() {
const {
type, className, options, innerRef, ...attributes // eslint-disable-line react/prop-types
} = this.props;
const htmlTag = this.getHTMLTagForType();
const htmlProps = {
className: classNames(this.getClassNameForType(), className),
type: htmlTag === 'input' ? type : undefined,
...attributes,
ref: this.getRef(innerRef),
};
const htmlChildren = type === 'select' ? this.renderOptions(options) : null;
return React.createElement(htmlTag, htmlProps, htmlChildren);
}
}
Input.propTypes = {
type: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
className: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
disabled: PropTypes.bool,
group: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
disabled: PropTypes.bool,
})),
})),
};
Input.defaultProps = {
className: undefined,
options: [],
};
// eslint-disable-next-line react/no-multi-comp
export default React.forwardRef((props, ref) => <Input innerRef={ref} {...props} />);

View File

@@ -1,6 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TransitionReplace } from '@openedx/paragon';
import { TransitionReplace } from '@edx/paragon';
const onChildExit = (htmlNode) => {
// If the leaving child has focus, take control and redirect it
@@ -10,9 +11,7 @@ const onChildExit = (htmlNode) => {
const enteringChild = htmlNode.previousSibling || htmlNode.nextSibling;
// There's no replacement, do nothing.
if (!enteringChild) {
return;
}
if (!enteringChild) return;
// Get all the focusable elements in the entering child and focus the first one
const focusableElements = enteringChild.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
@@ -22,15 +21,15 @@ const onChildExit = (htmlNode) => {
}
};
const SwitchContent = ({ expression, cases, className }) => {
function SwitchContent({ expression, cases, className }) {
const getContent = (caseKey) => {
if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') {
return getContent(cases[caseKey]);
}
return React.cloneElement(cases[caseKey], { key: caseKey });
}
if (cases.default) {
} else if (cases.default) {
if (typeof cases.default === 'string') {
return getContent(cases.default);
}
@@ -48,7 +47,8 @@ const SwitchContent = ({ expression, cases, className }) => {
{getContent(expression)}
</TransitionReplace>
);
};
}
SwitchContent.propTypes = {
expression: PropTypes.string,
@@ -61,4 +61,5 @@ SwitchContent.defaultProps = {
className: null,
};
export default SwitchContent;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Input from './Input';
const propTypes = {
for: PropTypes.string.isRequired,
className: PropTypes.string,
invalid: PropTypes.bool,
valid: PropTypes.bool,
validMessage: PropTypes.node,
invalidMessage: PropTypes.node,
helpText: PropTypes.node,
children: PropTypes.node,
};
const defaultProps = {
invalid: undefined,
valid: undefined,
validMessage: undefined,
invalidMessage: undefined,
helpText: undefined,
children: undefined,
className: undefined,
};
function ValidationFormGroup(props) {
const {
className,
invalidMessage,
invalid,
valid,
validMessage,
helpText,
for: id,
children,
} = props;
const renderChildren = () => React.Children.map(children, (child) => {
// Any non-user input element should pass through unmodified
if (['input', 'textarea', 'select', Input].indexOf(child.type) === -1) return child;
// Add validation class names and describedby values to input element
return React.cloneElement(child, {
className: classNames(child.props.className, {
'is-invalid': invalid,
'is-valid': valid,
}),
// This is a non-standard use of the classNames package, but it's exactly the same use case.
'aria-describedby': classNames(child.props['aria-describedby'], {
[`${id}-help-text`]: Boolean(helpText),
[`${id}-invalid-feedback`]: invalid && invalidMessage,
[`${id}-valid-feedback`]: valid && validMessage,
}),
});
});
const renderHelpText = (text) => {
if (!text) return null;
return <small id={`${id}-help-text`} className="form-text text-muted">{text}</small>;
};
const renderFeedback = (message, state) => {
if (!message) return null;
return (
<div
className={`${state}-feedback`}
id={`${id}-${state}-feedback`}
>
{message}
</div>
);
};
return (
<div className={classNames('form-group', className)}>
{renderChildren()}
{renderHelpText(helpText)}
{renderFeedback(invalidMessage, 'invalid')}
{renderFeedback(validMessage, 'valid')}
</div>
);
}
ValidationFormGroup.propTypes = propTypes;
ValidationFormGroup.defaultProps = defaultProps;
export default ValidationFormGroup;

View File

@@ -0,0 +1,32 @@
export const YEAR_OF_BIRTH_OPTIONS = (() => {
const currentYear = new Date().getFullYear();
const years = [];
let startYear = currentYear - 120;
while (startYear < currentYear) {
startYear += 1;
years.push({ value: startYear, label: startYear });
}
return years.reverse();
})();
export const EDUCATION_LEVELS = [
null,
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'o',
];
export const GENDER_OPTIONS = [
null,
'f',
'm',
'o',
];

View File

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

View File

@@ -1,141 +0,0 @@
export const YEAR_OF_BIRTH_OPTIONS = (() => {
const currentYear = new Date().getFullYear();
const years = [];
let startYear = currentYear - 120;
while (startYear < currentYear) {
startYear += 1;
years.push({ value: startYear, label: startYear });
}
return years.reverse();
})();
export const COPPA_COMPLIANCE_YEAR = (() => {
const currentYear = new Date().getFullYear();
return currentYear - 13;
})();
export const EDUCATION_LEVELS = [
'',
'p',
'm',
'b',
'a',
'hs',
'jhs',
'el',
'none',
'other',
];
export const GENDER_OPTIONS = [
'',
'f',
'm',
'o',
];
export const WORK_EXPERIENCE_OPTIONS = [
'',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10+',
];
export const COUNTRY_WITH_STATES = 'US';
export const TRANSIFEX_LANGUAGE_BASE_URL = 'https://www.transifex.com/open-edx/edx-platform/language/';
const COUNTRY_STATES_MAP = {
CA: [
{ value: 'AB', label: 'Alberta' },
{ value: 'BC', label: 'British Columbia' },
{ value: 'MB', label: 'Manitoba' },
{ value: 'NB', label: 'New Brunswick' },
{ value: 'NL', label: 'Newfoundland and Labrador' },
{ value: 'NS', label: 'Nova Scotia' },
{ value: 'NT', label: 'Northwest Territories' },
{ value: 'NU', label: 'Nunavut' },
{ value: 'ON', label: 'Ontario' },
{ value: 'PE', label: 'Prince Edward Island' },
{ value: 'QC', label: 'Québec' },
{ value: 'SK', label: 'Saskatchewan' },
{ value: 'YT', label: 'Yukon' },
],
US: [
{ value: 'AL', label: 'Alabama' },
{ value: 'AK', label: 'Alaska' },
{ value: 'AZ', label: 'Arizona' },
{ value: 'AR', label: 'Arkansas' },
{ value: 'AA', label: 'Armed Forces Americas' },
{ value: 'AE', label: 'Armed Forces Europe' },
{ value: 'AP', label: 'Armed Forces Pacific' },
{ value: 'CA', label: 'California' },
{ value: 'CO', label: 'Colorado' },
{ value: 'CT', label: 'Connecticut' },
{ value: 'DE', label: 'Delaware' },
{ value: 'DC', label: 'District Of Columbia' },
{ value: 'FL', label: 'Florida' },
{ value: 'GA', label: 'Georgia' },
{ value: 'HI', label: 'Hawaii' },
{ value: 'ID', label: 'Idaho' },
{ value: 'IL', label: 'Illinois' },
{ value: 'IN', label: 'Indiana' },
{ value: 'IA', label: 'Iowa' },
{ value: 'KS', label: 'Kansas' },
{ value: 'KY', label: 'Kentucky' },
{ value: 'LA', label: 'Louisiana' },
{ value: 'ME', label: 'Maine' },
{ value: 'MD', label: 'Maryland' },
{ value: 'MA', label: 'Massachusetts' },
{ value: 'MI', label: 'Michigan' },
{ value: 'MN', label: 'Minnesota' },
{ value: 'MS', label: 'Mississippi' },
{ value: 'MO', label: 'Missouri' },
{ value: 'MT', label: 'Montana' },
{ value: 'NE', label: 'Nebraska' },
{ value: 'NV', label: 'Nevada' },
{ value: 'NH', label: 'New Hampshire' },
{ value: 'NJ', label: 'New Jersey' },
{ value: 'NM', label: 'New Mexico' },
{ value: 'NY', label: 'New York' },
{ value: 'NC', label: 'North Carolina' },
{ value: 'ND', label: 'North Dakota' },
{ value: 'OH', label: 'Ohio' },
{ value: 'OK', label: 'Oklahoma' },
{ value: 'OR', label: 'Oregon' },
{ value: 'PA', label: 'Pennsylvania' },
{ value: 'RI', label: 'Rhode Island' },
{ value: 'SC', label: 'South Carolina' },
{ value: 'SD', label: 'South Dakota' },
{ value: 'TN', label: 'Tennessee' },
{ value: 'TX', label: 'Texas' },
{ value: 'UT', label: 'Utah' },
{ value: 'VT', label: 'Vermont' },
{ value: 'VA', label: 'Virginia' },
{ value: 'WA', label: 'Washington' },
{ value: 'WV', label: 'West Virginia' },
{ value: 'WI', label: 'Wisconsin' },
{ value: 'WY', label: 'Wyoming' },
],
};
export function getStatesList(country) {
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
}
export const FIELD_LABELS = {
COUNTRY: 'country',
};
export const DECLINED = 'declined';
export const SELF_DESCRIBE = 'self-describe';
export const OTHER = 'other';

View File

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

View File

@@ -1,171 +0,0 @@
import {
call, put, delay, takeEvery, all,
} from 'redux-saga/effects';
import { publish } from '@edx/frontend-platform';
import { getLocale, handleRtl, LOCALE_CHANGED } from '@edx/frontend-platform/i18n';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
// Actions
import {
FETCH_SETTINGS,
fetchSettingsBegin,
fetchSettingsSuccess,
fetchSettingsFailure,
closeForm,
SAVE_SETTINGS,
SAVE_MULTIPLE_SETTINGS,
saveSettingsBegin,
saveSettingsSuccess,
saveSettingsFailure,
savePreviousSiteLanguage,
FETCH_TIME_ZONES,
fetchTimeZones,
fetchTimeZonesSuccess,
saveMultipleSettingsBegin,
saveMultipleSettingsSuccess,
saveMultipleSettingsFailure,
beginNameChange,
} from './actions';
// Sub-modules
import { saga as deleteAccountSaga } from '../delete-account';
import { saga as resetPasswordSaga } from '../reset-password';
import { saga as nameChangeSaga } from '../name-change';
import {
saga as siteLanguageSaga,
patchPreferences,
postSetLang,
} from '../site-language';
import { saga as thirdPartyAuthSaga } from '../third-party-auth';
// Services
import {
getSettings,
patchSettings,
getTimeZones,
getVerifiedNameHistory,
} from './service';
export function* handleFetchSettings() {
try {
yield put(fetchSettingsBegin());
const { username, userId, roles: userRoles } = getAuthenticatedUser();
const {
thirdPartyAuthProviders, profileDataManager, timeZones, countries, ...values
} = yield call(
getSettings,
username,
userRoles,
userId,
);
const verifiedNameHistory = yield call(getVerifiedNameHistory);
if (values.country) { yield put(fetchTimeZones(values.country)); }
yield put(fetchSettingsSuccess({
values,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
countriesCodesList: countries,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));
throw e;
}
}
export function* handleSaveSettings(action) {
try {
yield put(saveSettingsBegin());
const { username, userId } = getAuthenticatedUser();
const { commitValues, formId, extendedProfile } = action.payload;
const commitData = Object.keys(extendedProfile).length > 0 ? extendedProfile : { [formId]: commitValues };
let savedValues = null;
if (formId === 'siteLanguage') {
const previousSiteLanguage = getLocale();
// The following two requests need to be done sequentially, with patching preferences before
// the post to setlang. They used to be done in parallel, but this might create ambiguous
// behavior.
yield call(patchPreferences, username, { prefLang: commitValues });
yield call(postSetLang, commitValues);
yield put(savePreviousSiteLanguage(previousSiteLanguage));
publish(LOCALE_CHANGED, getLocale());
handleRtl();
savedValues = commitData;
} else {
savedValues = yield call(patchSettings, username, commitData, userId);
}
yield put(saveSettingsSuccess(savedValues, commitData));
if (savedValues.country) { yield put(fetchTimeZones(savedValues.country)); }
yield delay(1000);
yield put(closeForm(action.payload.formId));
} catch (e) {
if (e.fieldErrors) {
if (e.fieldErrors.name?.includes('verification')) {
yield put(beginNameChange('name'));
}
yield put(saveSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveSettingsFailure(e.message));
throw e;
}
}
}
// handles mutiple settings saved at once, in order, and stops executing on first failure.
export function* handleSaveMultipleSettings(action) {
try {
yield put(saveMultipleSettingsBegin());
const { username, userId } = getAuthenticatedUser();
const { settingsArray, form } = action.payload;
for (let i = 0; i < settingsArray.length; i += 1) {
const { formId, commitValues } = settingsArray[i];
yield put(saveSettingsBegin());
const commitData = { [formId]: commitValues };
const savedSettings = yield call(patchSettings, username, commitData, userId);
yield put(saveSettingsSuccess(savedSettings, commitData));
}
yield put(saveMultipleSettingsSuccess(action));
if (form) {
yield delay(1000);
yield put(closeForm(form));
}
} catch (e) {
if (e.fieldErrors) {
if (e.fieldErrors.name?.includes('verification')) {
yield put(beginNameChange('name'));
}
yield put(saveMultipleSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveMultipleSettingsFailure(e.message));
throw e;
}
}
}
export function* handleFetchTimeZones(action) {
const response = yield call(getTimeZones, action.payload.country);
yield put(fetchTimeZonesSuccess(response, action.payload.country));
}
export default function* saga() {
yield takeEvery(FETCH_SETTINGS.BASE, handleFetchSettings);
yield takeEvery(SAVE_SETTINGS.BASE, handleSaveSettings);
yield takeEvery(SAVE_MULTIPLE_SETTINGS.BASE, handleSaveMultipleSettings);
yield takeEvery(FETCH_TIME_ZONES.BASE, handleFetchTimeZones);
yield all([
deleteAccountSaga(),
siteLanguageSaga(),
resetPasswordSaga(),
nameChangeSaga(),
thirdPartyAuthSaga(),
]);
}

View File

@@ -1,316 +0,0 @@
import { createSelector, createStructuredSelector } from 'reselect';
import { siteLanguageListSelector, siteLanguageOptionsSelector } from '../site-language';
import { compareVerifiedNamesByCreatedDate } from '../../utils';
export const storeName = 'accountSettings';
export const accountSettingsSelector = state => ({ ...state[storeName] });
const editableFieldNameSelector = (state, props) => props.name;
const verifiedNameSettingsSelector = createSelector(
accountSettingsSelector,
accountSettings => ({
history: accountSettings.verifiedNameHistory.results,
useVerifiedNameForCerts: accountSettings?.verifiedNameHistory.use_verified_name_for_certs,
}),
);
const sortedVerifiedNameHistorySelector = createSelector(
verifiedNameSettingsSelector,
verifiedNameSettings => {
const { history } = verifiedNameSettings;
if (Array.isArray(history)) {
return history.sort(compareVerifiedNamesByCreatedDate);
}
return [];
},
);
const mostRecentVerifiedNameSelector = createSelector(
sortedVerifiedNameHistorySelector,
sortedHistory => (sortedHistory.length > 0 ? sortedHistory[0] : null),
);
const mostRecentApprovedVerifiedNameValueSelector = createSelector(
sortedVerifiedNameHistorySelector,
mostRecentVerifiedNameSelector,
(sortedHistory, mostRecentVerifiedName) => {
const approvedVerifiedNames = sortedHistory.filter(name => name.status === 'approved');
const approvedVerifiedName = approvedVerifiedNames.length > 0 ? approvedVerifiedNames[0] : null;
let verifiedName = null;
switch (mostRecentVerifiedName && mostRecentVerifiedName.status) {
case 'approved':
case 'denied':
case 'pending':
verifiedName = approvedVerifiedName;
break;
case 'submitted':
verifiedName = mostRecentVerifiedName;
break;
default:
verifiedName = null;
}
return verifiedName;
},
);
const valuesSelector = createSelector(
accountSettingsSelector,
mostRecentApprovedVerifiedNameValueSelector,
(accountSettings, mostRecentApprovedVerifiedNameValue) => {
let useVerifiedNameForCerts = (
accountSettings.verifiedNameHistory?.use_verified_name_for_certs || false
);
if (Object.keys(accountSettings.confirmationValues).includes('useVerifiedNameForCerts')) {
useVerifiedNameForCerts = accountSettings.confirmationValues.useVerifiedNameForCerts;
}
return {
...accountSettings.values,
verified_name: mostRecentApprovedVerifiedNameValue?.verified_name,
useVerifiedNameForCerts,
};
},
);
const draftsSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.drafts,
);
const previousSiteLanguageSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.previousSiteLanguage,
);
const countriesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.countriesCodesList,
);
const editableFieldErrorSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
(name, accountSettings) => accountSettings.errors[name],
);
const editableFieldConfirmationValuesSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
(name, accountSettings) => accountSettings.confirmationValues[name],
);
const isEditingSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
(name, accountSettings) => accountSettings.openFormId === name,
);
const errorSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.errors,
);
const nameChangeModalSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.nameChangeModal,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
);
export const editableFieldSelector = createStructuredSelector({
error: editableFieldErrorSelector,
confirmationValue: editableFieldConfirmationValuesSelector,
saveState: saveStateSelector,
isEditing: isEditingSelector,
});
export const profileDataManagerSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.profileDataManager,
);
export const staticFieldsSelector = createSelector(
accountSettingsSelector,
mostRecentVerifiedNameSelector,
(accountSettings, verifiedName) => {
const staticFields = [];
if (accountSettings.profileDataManager) {
staticFields.push('name', 'email', 'country');
}
if (verifiedName && ['submitted'].includes(verifiedName.status)) {
staticFields.push('verifiedName');
}
return staticFields;
},
);
/**
* If there's no draft present at all (undefined), use the original committed value.
*/
function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed;
}
export const formValuesSelector = createSelector(
valuesSelector,
draftsSelector,
(values, drafts) => {
const formValues = {};
Object.entries(values).forEach(([name, value]) => {
if (typeof value === 'boolean') {
formValues[name] = chooseFormValue(drafts[name], value);
} else if (typeof value === 'object' && name === 'extended_profile' && value !== null) {
const extendedProfile = value.slice();
const draftsKeys = Object.keys(drafts);
if (draftsKeys.length !== 0) {
const draftFieldName = draftsKeys[0];
const index = extendedProfile.findIndex((profile) => profile.field_name === draftFieldName);
if (index !== -1) {
extendedProfile[index] = { field_name: draftFieldName, field_value: drafts[draftFieldName] };
}
}
formValues.extended_profile = [...extendedProfile];
} else {
formValues[name] = chooseFormValue(drafts[name], value) || '';
}
});
return formValues;
},
);
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
.map(({ time_zone, description }) => ({ // eslint-disable-line camelcase
value: time_zone, label: description, // eslint-disable-line camelcase
}));
const timeZonesSelector = createSelector(
accountSettingsSelector,
accountSettings => transformTimeZonesToOptions(accountSettings.timeZones),
);
const countryTimeZonesSelector = createSelector(
accountSettingsSelector,
accountSettings => transformTimeZonesToOptions(accountSettings.countryTimeZones),
);
const activeAccountSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.values.is_active,
);
export const siteLanguageSelector = createSelector(
previousSiteLanguageSelector,
draftsSelector,
(previousValue, drafts) => ({
previousValue,
draft: drafts.siteLanguage,
}),
);
export const betaLanguageBannerSelector = createStructuredSelector({
siteLanguageList: siteLanguageListSelector,
siteLanguage: siteLanguageSelector,
});
export const accountSettingsPageSelector = createSelector(
accountSettingsSelector,
siteLanguageOptionsSelector,
siteLanguageSelector,
formValuesSelector,
valuesSelector,
draftsSelector,
errorSelector,
profileDataManagerSelector,
staticFieldsSelector,
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
nameChangeModalSelector,
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
countriesSelector,
(
accountSettings,
siteLanguageOptions,
siteLanguage,
formValues,
committedValues,
drafts,
formErrors,
profileDataManager,
staticFields,
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
) => ({
siteLanguageOptions,
siteLanguage,
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
timeZoneOptions,
countryTimeZoneOptions,
isActive: activeAccount,
formValues,
committedValues,
drafts,
formErrors,
profileDataManager,
staticFields,
tpaProviders: accountSettings.thirdPartyAuth.providers,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
}),
);
export const certPreferenceSelector = createSelector(
valuesSelector,
formValuesSelector,
mostRecentApprovedVerifiedNameValueSelector,
saveStateSelector,
errorSelector,
(
committedValues,
formValues,
mostRecentApprovedVerifiedNameValue,
saveState,
errors,
) => ({
originalFullName: committedValues?.name || '',
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
saveState,
formErrors: errors,
}),
);
export const nameChangeSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
(accountSettings, formValues) => ({
...accountSettings.nameChange,
formValues,
}),
);

View File

@@ -1,72 +0,0 @@
import { profileDataManagerSelector, formValuesSelector } from './selectors';
const testValue = 'test VALUE';
describe('profileDataManagerSelector', () => {
it('returns the profileDataManager from the state', () => {
const state = {
accountSettings: {
profileDataManager: { testValue },
},
};
const result = profileDataManagerSelector(state);
expect(result).toEqual(state.accountSettings.profileDataManager);
});
it('should correctly select form values', () => {
const state = {
accountSettings: {
values: {
name: 'John Doe',
age: 25,
},
drafts: {
age: 26,
},
verifiedNameHistory: 'test',
confirmationValues: {},
},
};
const result = formValuesSelector(state);
const expected = {
name: 'John Doe',
age: 26,
verified_name: '',
useVerifiedNameForCerts: false,
};
expect(result).toEqual(expected);
});
it('should correctly select form values with extended_profile', () => {
// Mock data with extended_profile field in both values and drafts
const state = {
accountSettings: {
values: {
extended_profile: [
{ field_name: 'test_field', field_value: '5' },
],
},
drafts: { test_field: '6' },
verifiedNameHistory: 'test',
confirmationValues: {},
},
};
const result = formValuesSelector(state);
const expected = {
verified_name: '',
useVerifiedNameForCerts: false,
extended_profile: [ // Draft value should override the committed value
{ field_name: 'test_field', field_value: '6' }, // Value from the committed values
],
};
expect(result).toEqual(expected);
});
});

View File

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

View File

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

@@ -1,6 +0,0 @@
export {
AsyncActionType,
getModuleState,
} from './reduxUtils';
export { default as handleFailure } from './sagaUtils';
export { unpackFieldErrors, handleRequestError } from './serviceUtils';

View File

@@ -1,66 +0,0 @@
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*/
export class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
get FORBIDDEN() {
return `${this.topic}__${this.name}__FORBIDDEN`;
}
}
/**
* Given a state tree and an array representing a set of keys to traverse in that tree, returns
* the portion of the tree at that key path.
*
* Example:
*
* const result = getModuleState(
* {
* first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
* second: { other: 'data', }
* },
* ['first', 'red']
* );
*
* result will be:
*
* {
* awesome: 'sauce'
* }
*/
export function getModuleState(state, originalPath) {
const path = [...originalPath]; // don't modify your argument
if (path.length < 1) {
return state;
}
const key = path.shift();
if (state[key] === undefined) {
throw new Error(`Unexpected state key ${key} given to getModuleState. Is your state path set up correctly?`);
}
return getModuleState(state[key], path);
}

View File

@@ -1,52 +0,0 @@
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');
expect(actionType.FORBIDDEN).toBe('HOUSE_CATS__START_THE_RACE__FORBIDDEN');
});
});
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,15 +0,0 @@
import { put } from 'redux-saga/effects';
import { logError } from '@edx/frontend-platform/logging';
export default function* handleFailure(error, navigate, failureAction = null, failureRedirectPath = null) {
if (error.fieldErrors && failureAction !== null) {
yield put(failureAction({ fieldErrors: error.fieldErrors }));
}
logError(error);
if (failureAction !== null) {
yield put(failureAction(error.message));
}
if (failureRedirectPath !== null) {
navigate(failureRedirectPath);
}
}

View File

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

View File

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

View File

@@ -1,50 +0,0 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl, createIntl } from '@edx/frontend-platform/i18n';
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn(node => node), // Mock portal behavior
}));
import BeforeProceedingBanner from './BeforeProceedingBanner'; // eslint-disable-line import/first
const IntlBeforeProceedingBanner = injectIntl(BeforeProceedingBanner);
describe('BeforeProceedingBanner', () => {
it('should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link', () => {
const props = {
instructionMessageId: 'account.settings.delete.account.please.unlink',
intl: createIntl({ locale: 'en' }),
supportArticleUrl: '',
};
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlBeforeProceedingBanner
{...props}
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link', () => {
const props = {
instructionMessageId: 'account.settings.delete.account.please.unlink',
intl: createIntl({ locale: 'en' }),
supportArticleUrl: 'http://test-support.edx',
};
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlBeforeProceedingBanner
{...props}
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,148 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
AlertModal,
Button, Input, ValidationFormGroup, ActionRow,
} from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
import Alert from '../Alert';
import PrintingInstructions from './PrintingInstructions';
export class ConfirmationModal extends Component {
/**
* @returns String The message id for a short description of the error, suitable for a header or
* as the error message under an input field.
*/
getShortErrorMessageId(reason) {
switch (reason) {
case 'empty-password':
return 'account.settings.delete.account.error.no.password';
case 'invalid-password':
return 'account.settings.delete.account.error.invalid.password';
default:
return 'account.settings.delete.account.error.unable.to.delete';
}
}
renderError(reason) {
const { errorType, intl } = this.props;
if (errorType === null) {
return null;
}
const headerMessageId = this.getShortErrorMessageId(errorType);
const detailsMessageId = reason === 'empty-password'
? null
: 'account.settings.delete.account.error.unable.to.delete.details';
return (
<Alert
className="alert-danger mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationCircle} />}
>
<h6>{intl.formatMessage(messages[headerMessageId])}</h6>
{detailsMessageId ? (
<p className="text-danger">{intl.formatMessage(messages[detailsMessageId])}</p>
) : null}
</Alert>
);
}
render() {
const {
status,
errorType,
intl,
onCancel,
onChange,
onSubmit,
password,
} = this.props;
const open = ['confirming', 'pending', 'failed'].includes(status);
const passwordFieldId = 'passwordFieldId';
const invalidMessage = messages[this.getShortErrorMessageId(errorType)];
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
// to allow edx.org to fulfill its business requirements.
const deleteAccountModalText2MessageKey = getConfig().SITE_NAME === 'edX'
? 'account.settings.delete.account.modal.text.2.edX'
: 'account.settings.delete.account.modal.text.2';
return (
<AlertModal
isOpen={open}
title={intl.formatMessage(messages['account.settings.delete.account.modal.header'])}
onClose={onCancel}
footerNode={(
<ActionRow>
<Button variant="link" onClick={onCancel}>Cancel</Button>
<Button variant="danger" onClick={onSubmit}>Yes, Delete</Button>
</ActionRow>
)}
>
<div className="p-3">
{this.renderError()}
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<h6>
{intl.formatMessage(
messages['account.settings.delete.account.modal.text.1'],
{ siteName: getConfig().SITE_NAME },
)}
</h6>
<p>
{intl.formatMessage(
messages[deleteAccountModalText2MessageKey],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
<PrintingInstructions />
</p>
</Alert>
<ValidationFormGroup
for={passwordFieldId}
invalid={errorType !== null}
invalidMessage={intl.formatMessage(invalidMessage)}
>
<label className="d-block" htmlFor={passwordFieldId}>
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
</label>
<Input
name="password"
id={passwordFieldId}
type="password"
value={password}
onChange={onChange}
/>
</ValidationFormGroup>
</div>
</AlertModal>
);
}
}
ConfirmationModal.propTypes = {
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
errorType: PropTypes.oneOf(['empty-password', 'server']),
intl: intlShape.isRequired,
onCancel: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
password: PropTypes.string.isRequired,
};
ConfirmationModal.defaultProps = {
status: null,
errorType: null,
};
export default injectIntl(ConfirmationModal);

View File

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

View File

@@ -1,186 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@openedx/paragon';
// Actions
import {
deleteAccount,
deleteAccountConfirmation,
deleteAccountFailure,
deleteAccountReset,
deleteAccountCancel,
} from './data/actions';
// Messages
import messages from './messages';
// Components
import ConnectedConfirmationModal from './ConfirmationModal';
import PrintingInstructions from './PrintingInstructions';
import ConnectedSuccessModal from './SuccessModal';
import BeforeProceedingBanner from './BeforeProceedingBanner';
export class DeleteAccount extends React.Component {
constructor(props) {
super(props);
this.state = {
password: '',
};
}
handleSubmit = () => {
if (this.state.password === '') {
this.props.deleteAccountFailure('empty-password');
} else {
this.props.deleteAccount(this.state.password);
}
};
handleCancel = () => {
this.setState({ password: '' });
this.props.deleteAccountCancel();
};
handlePasswordChange = (e) => {
this.setState({ password: e.target.value.trim() });
this.props.deleteAccountReset();
};
handleFinalClose = () => {
global.location = getConfig().LOGOUT_URL;
};
render() {
const {
hasLinkedTPA, isVerifiedAccount, status, errorType, intl,
} = this.props;
const canDelete = isVerifiedAccount && !hasLinkedTPA;
const supportArticleUrl = process.env.SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT;
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
// to allow edx.org to fulfill its business requirements.
const deleteAccountText2MessageKey = getConfig().SITE_NAME === 'edX'
? 'account.settings.delete.account.text.2.edX'
: 'account.settings.delete.account.text.2';
const optInInstructionMessageId = getConfig().MARKETING_EMAILS_OPT_IN
? 'account.settings.delete.account.please.confirm'
: 'account.settings.delete.account.please.activate';
return (
<div>
<h2 className="section-heading h4 mb-3">
{intl.formatMessage(messages['account.settings.delete.account.header'])}
</h2>
{
this.props.canDeleteAccount ? (
<>
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
<p>
{intl.formatMessage(
messages['account.settings.delete.account.text.1'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
{intl.formatMessage(
messages[deleteAccountText2MessageKey],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
<PrintingInstructions />
</p>
<p className="text-danger h6">
{intl.formatMessage(
messages['account.settings.delete.account.text.warning'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
</Hyperlink>
</p>
<p>
<Button
variant="outline-danger"
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
disabled={!canDelete}
>
{intl.formatMessage(messages['account.settings.delete.account.button'])}
</Button>
</p>
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId={optInInstructionMessageId}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
/>
)}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportArticleUrl={supportArticleUrl}
/>
) : null}
<ConnectedConfirmationModal
status={status}
errorType={errorType}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
onChange={this.handlePasswordChange}
password={this.state.password}
/>
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
</>
) : (
<p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
)
}
</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,
canDeleteAccount: PropTypes.bool,
intl: intlShape.isRequired,
};
DeleteAccount.defaultProps = {
hasLinkedTPA: false,
isVerifiedAccount: true,
status: null,
errorType: null,
canDeleteAccount: true,
};
// 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,76 +0,0 @@
/* eslint-disable react/jsx-no-useless-fragment */
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Testing the modals separately, they just clutter up the snapshots if included here.
jest.mock('./ConfirmationModal', () => function ConfirmationModalMock() {
return <></>;
});
jest.mock('./SuccessModal', () => function SuccessModalMock() {
return <></>;
});
import { DeleteAccount } from './DeleteAccount'; // eslint-disable-line import/first
const IntlDeleteAccount = injectIntl(DeleteAccount);
describe('DeleteAccount', () => {
let props = {};
beforeEach(() => {
props = {
deleteAccount: jest.fn(),
deleteAccountConfirmation: jest.fn(),
deleteAccountFailure: jest.fn(),
deleteAccountReset: jest.fn(),
deleteAccountCancel: jest.fn(),
status: null,
errorType: null,
hasLinkedTPA: false,
isVerifiedAccount: true,
};
});
it('should match default section snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlDeleteAccount
{...props}
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match unverified account section snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlDeleteAccount
{...props}
isVerifiedAccount={false}
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should match unverified account section snapshot', () => {
const tree = renderer
.create((
<IntlProvider locale="en">
<IntlDeleteAccount
{...props}
hasLinkedTPA
/>
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,47 +0,0 @@
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const PrintingInstructions = (props) => {
const actionLink = (
<Hyperlink
// TODO: What would a generic version of this link look like? Should
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
// We've removed the link from the default message.
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
>
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
</Hyperlink>
);
// TODO: We lack a good way of providing custom language for a particular site. This is a hack
// to allow edx.org to mention MicroMasters certificates to fulfill its business requirements.
if (getConfig().SITE_NAME === 'edX') {
return (
<FormattedMessage
id="account.settings.delete.account.text.3.edX"
defaultMessage="You may also lose access to verified certificates and other program credentials like MicroMasters certificates. You can make a copy of these for your records before proceeding with deletion. {actionLink}."
description="A message in the user account deletion area warning users that deleting their account will prevent them from accessing their certificates. 'actionLink' is a HTML link with a full sentence that describes how to print a certificate."
values={{ actionLink }}
/>
);
}
return (
<FormattedMessage
id="account.settings.delete.account.text.3"
defaultMessage="You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion."
description="A message in the user account deletion area warning users that deleting their account will prevent them from accessing their certificates."
values={{ actionLink }}
/>
);
};
PrintingInstructions.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(PrintingInstructions);

View File

@@ -1,42 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ModalLayer, ModalCloseButton } from '@openedx/paragon';
import messages from './messages';
export const SuccessModal = (props) => {
const { status, intl, onClose } = props;
return (
<ModalLayer isOpen={status === 'deleted'} onClose={onClose}>
<div className="mw-sm p-5 bg-white mx-auto my-3">
<h3>
{intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])}
</h3>
<div className="p-3">
<p className="h6">
{intl.formatMessage(messages['account.settings.delete.account.modal.after.text'])}
</p>
</div>
<p>
<ModalCloseButton className="float-right" variant="link">Close</ModalCloseButton>
</p>
</div>
</ModalLayer>
);
};
SuccessModal.propTypes = {
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
SuccessModal.defaultProps = {
status: null,
};
export default injectIntl(SuccessModal);

View File

@@ -1,61 +0,0 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { waitFor } from '@testing-library/react';
import { SuccessModal } from './SuccessModal';
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn(node => node), // Mock portal behavior
}));
const IntlSuccessModal = injectIntl(SuccessModal);
describe('SuccessModal', () => {
let props = {};
beforeEach(() => {
props = {
onClose: jest.fn(),
status: null,
};
});
it('should match default closed success modal snapshot', async () => {
await waitFor(() => {
const tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} /></IntlProvider>)).toJSON();
expect(tree).toMatchSnapshot();
});
await waitFor(() => {
const tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} status="confirming" /></IntlProvider>)).toJSON();
expect(tree).toMatchSnapshot();
});
await waitFor(() => {
const tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} status="pending" /></IntlProvider>)).toJSON();
expect(tree).toMatchSnapshot();
});
await waitFor(() => {
const tree = renderer.create((
<IntlProvider locale="en"><IntlSuccessModal {...props} status="failed" /></IntlProvider>)).toJSON();
expect(tree).toMatchSnapshot();
});
});
it('should match open success modal snapshot', async () => {
await waitFor(() => {
const tree = renderer.create(
<IntlProvider locale="en">
<IntlSuccessModal
{...props}
status="deleted"
/>
</IntlProvider>,
).toJSON();
expect(tree).toMatchSnapshot();
});
});
});

View File

@@ -1,67 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`BeforeProceedingBanner should match the snapshot if SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT does not have a support link 1`] = `
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
fill="currentColor"
style={{}}
/>
</svg>
</div>
<div>
Before proceeding, please unlink all social media accounts.
</div>
</div>
`;
exports[`BeforeProceedingBanner should match the snapshot when SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT has a support link 1`] = `
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
fill="currentColor"
style={{}}
/>
</svg>
</div>
<div>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="http://test-support.edx"
target="_self"
>
unlink all social media accounts
</a>
.
</div>
</div>
`;

View File

@@ -1,374 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `null`;
exports[`ConfirmationModal should match empty password confirmation modal snapshot 1`] = `
[
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
className="pgn__modal-content-container"
>
<div
className="pgn__modal-backdrop"
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
/>
<div
aria-label="Are you sure?"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
role="dialog"
>
<div
className="pgn__modal-header"
>
<h2
className="pgn__modal-title"
>
Are you sure?
</h2>
</div>
<div
className="pgn__modal-body pgn__modal-body-scroll-top pgn__modal-body-scroll-bottom"
>
<div />
<div
className="pgn__modal-body-content"
>
<div
className="p-3"
>
<div
className="alert d-flex align-items-start alert-danger mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-circle-exclamation mr-2"
data-icon="circle-exclamation"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
fill="currentColor"
style={{}}
/>
</svg>
</div>
<div>
<h6>
A password is required
</h6>
<p
className="text-danger"
>
Sorry, there was an error trying to process your request. Please try again later.
</p>
</div>
</div>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
fill="currentColor"
style={{}}
/>
</svg>
</div>
<div>
<h6>
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
</h6>
<p>
If you proceed, you will be unable to use this account to take courses on localhost.
</p>
<p>
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
</p>
</div>
</div>
<div
className="form-group"
data-testid="validation-form-group"
>
<label
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<input
aria-describedby="passwordFieldId-invalid-feedback"
className="form-control is-invalid"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
>
A password is required
</strong>
</div>
</div>
</div>
<div />
</div>
<div
className="pgn__modal-footer"
>
<div
className="pgn__action-row"
>
<button
className="btn btn-link"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Cancel
</button>
<button
className="btn btn-danger"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Yes, Delete
</button>
</div>
</div>
</div>
</div>
</div>,
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>,
]
`;
exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
[
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
>
<div
className="pgn__modal-content-container"
>
<div
className="pgn__modal-backdrop"
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
/>
<div
aria-label="Are you sure?"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
role="dialog"
>
<div
className="pgn__modal-header"
>
<h2
className="pgn__modal-title"
>
Are you sure?
</h2>
</div>
<div
className="pgn__modal-body pgn__modal-body-scroll-top pgn__modal-body-scroll-bottom"
>
<div />
<div
className="pgn__modal-body-content"
>
<div
className="p-3"
>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
fill="currentColor"
style={{}}
/>
</svg>
</div>
<div>
<h6>
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
</h6>
<p>
If you proceed, you will be unable to use this account to take courses on localhost.
</p>
<p>
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
</p>
</div>
</div>
<div
className="form-group"
data-testid="validation-form-group"
>
<label
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<input
aria-describedby=""
className="form-control"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
>
Unable to delete account
</strong>
</div>
</div>
</div>
<div />
</div>
<div
className="pgn__modal-footer"
>
<div
className="pgn__action-row"
>
<button
className="btn btn-link"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Cancel
</button>
<button
className="btn btn-danger"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Yes, Delete
</button>
</div>
</div>
</div>
</div>
</div>,
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>,
]
`;

View File

@@ -1,207 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DeleteAccount should match default section snapshot 1`] = `
<div>
<h2
className="section-heading h4 mb-3"
>
Delete My Account
</h2>
<p>
We're sorry to see you go!
</p>
<p>
Please note: Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
</p>
<p>
Once your account is deleted, you cannot use it to take courses on localhost.
</p>
<p>
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
</p>
<p
className="text-danger h6"
>
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on localhost.
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self"
>
Want to change your email, name, or password instead?
</a>
</p>
<p>
<button
className="btn btn-outline-danger"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Delete My Account
</button>
</p>
</div>
`;
exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<div>
<h2
className="section-heading h4 mb-3"
>
Delete My Account
</h2>
<p>
We're sorry to see you go!
</p>
<p>
Please note: Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
</p>
<p>
Once your account is deleted, you cannot use it to take courses on localhost.
</p>
<p>
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
</p>
<p
className="text-danger h6"
>
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on localhost.
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self"
>
Want to change your email, name, or password instead?
</a>
</p>
<p>
<button
className="btn btn-outline-danger"
disabled={true}
onClick={null}
type="button"
>
Delete My Account
</button>
</p>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
fill="currentColor"
style={{}}
/>
</svg>
</div>
<div>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
target="_self"
>
activate your account
</a>
.
</div>
</div>
</div>
`;
exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<div>
<h2
className="section-heading h4 mb-3"
>
Delete My Account
</h2>
<p>
We're sorry to see you go!
</p>
<p>
Please note: Deletion of your account and personal data is permanent and cannot be undone. localhost will not be able to recover your account or the data that is deleted.
</p>
<p>
Once your account is deleted, you cannot use it to take courses on localhost.
</p>
<p>
You may also lose access to verified certificates and other program credentials. You can make a copy of these for your records before proceeding with deletion.
</p>
<p
className="text-danger h6"
>
Warning: Account deletion is permanent. Please read the above carefully before proceeding. This is an irreversible action, and you will no longer be able to use the same email on localhost.
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
target="_self"
>
Want to change your email, name, or password instead?
</a>
</p>
<p>
<button
className="btn btn-outline-danger"
disabled={true}
onClick={null}
type="button"
>
Delete My Account
</button>
</p>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
fill="currentColor"
style={{}}
/>
</svg>
</div>
<div>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
target="_self"
>
unlink all social media accounts
</a>
.
</div>
</div>
</div>
`;

View File

@@ -1,90 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SuccessModal should match default closed success modal snapshot 1`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 2`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 3`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 4`] = `null`;
exports[`SuccessModal should match open success modal snapshot 1`] = `
[
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
>
<div
className="pgn__modal-content-container"
>
<div
className="pgn__modal-backdrop"
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
/>
<div
className="mw-sm p-5 bg-white mx-auto my-3"
>
<h3>
We're sorry to see you go! Your account will be deleted shortly.
</h3>
<div
className="p-3"
>
<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>
<p>
<button
className="pgn__modal-close-button float-right btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Close
</button>
</p>
</div>
</div>
</div>,
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>,
]
`;

View File

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

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

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

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

View File

@@ -1,5 +0,0 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './DeleteAccount';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { DELETE_ACCOUNT } from './data/actions';

View File

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

View File

@@ -1,19 +0,0 @@
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
export const withNavigate = Component => {
const WrappedComponent = props => {
const navigate = useNavigate();
return <Component {...props} navigate={navigate} />;
};
return WrappedComponent;
};
export const withLocation = Component => {
const WrappedComponent = props => {
const location = useLocation();
return <Component {...props} location={location.pathname} />;
};
return WrappedComponent;
};

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { withLocation, withNavigate } from './hoc';
const mockedNavigator = jest.fn();
jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigator,
useLocation: () => ({
pathname: '/current-location',
}),
}));
// eslint-disable-next-line react/prop-types
const MockComponent = ({ navigate, location }) => (
// eslint-disable-next-line react/button-has-type, react/prop-types
<button data-testid="btn" onClick={() => navigate('/some-route')}>{location}</button>
);
const WrappedComponent = withNavigate(withLocation(MockComponent));
test('Provide Navigation to Component', () => {
render(
<WrappedComponent />,
);
const btn = screen.getByTestId('btn');
fireEvent.click(btn);
expect(mockedNavigator).toHaveBeenCalledWith('/some-route');
});
test('Provide Location Pathname to Component', () => {
render(
<WrappedComponent />,
);
expect(screen.getByTestId('btn').textContent).toContain('/current-location');
});

View File

@@ -1,6 +1,13 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './AccountSettingsPage';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { storeName } from './data/selectors';
export { default as NotFoundPage } from './NotFoundPage';
import ConnectedAccountSettingsPage from './AccountSettingsPage';
import reducer from './reducers';
import saga from './sagas';
import { configureApiService } from './service';
import { storeName } from './selectors';
export {
ConnectedAccountSettingsPage,
reducer,
saga,
configureApiService,
storeName,
};

View File

@@ -1,203 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { connect, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import PropTypes from 'prop-types';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
Button,
Col,
Form,
ModalDialog,
StatefulButton,
} from '@openedx/paragon';
import { closeForm, saveSettingsReset } from '../data/actions';
import { nameChangeSelector } from '../data/selectors';
import { requestNameChange, requestNameChangeFailure, requestNameChangeReset } from './data/actions';
import messages from './messages';
const NameChangeModal = ({
targetFormId,
errors,
formValues,
intl,
saveState,
}) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const { username } = getAuthenticatedUser();
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
const [confirmedWarning, setConfirmedWarning] = useState(false);
const resetLocalState = useCallback(() => {
setConfirmedWarning(false);
dispatch(requestNameChangeReset());
}, [dispatch]);
const handleChange = (e) => {
setVerifiedNameInput(e.target.value);
};
const handleClose = useCallback(() => {
resetLocalState();
dispatch(closeForm(targetFormId));
dispatch(saveSettingsReset());
}, [dispatch, resetLocalState, targetFormId]);
const handleSubmit = (e) => {
e.preventDefault();
if (saveState === 'pending') {
return;
}
if (!verifiedNameInput) {
dispatch(requestNameChangeFailure({
verified_name: intl.formatMessage(messages['account.settings.name.change.error.valid.name']),
}));
} else {
const draftProfileName = targetFormId === 'name' ? formValues.name : null;
dispatch(requestNameChange(username, draftProfileName, verifiedNameInput));
}
};
useEffect(() => {
if (saveState === 'complete') {
handleClose();
navigate(`/id-verification?next=${encodeURIComponent('account/settings')}`);
}
}, [handleClose, navigate, saveState]);
function renderErrors() {
if (Object.keys(errors).length > 0) {
return (
<>
{Object.entries(errors).map(([key, value]) => (
<Form.Control.Feedback type="invalid" key={key}>
{
key === 'general_error'
? intl.formatMessage(messages['account.settings.name.change.error.general'])
: value
}
</Form.Control.Feedback>
))}
</>
);
}
return null;
}
function renderTitle() {
if (!confirmedWarning) {
return intl.formatMessage(messages['account.settings.name.change.title.id']);
}
return intl.formatMessage(messages['account.settings.name.change.title.begin']);
}
function renderBody() {
if (!confirmedWarning) {
return (
<Alert variant="warning">
<p>
{intl.formatMessage(messages['account.settings.name.change.warning.one'])}
</p>
<p>
{intl.formatMessage(messages['account.settings.name.change.warning.two'])}
</p>
</Alert>
);
}
return (
<Form.Group as={Col} isInvalid={Object.keys(errors).length > 0}>
<Form.Label>
{intl.formatMessage(messages['account.settings.name.change.id.name.label'])}
</Form.Label>
<Form.Control
type="text"
name="verifiedName"
placeholder={intl.formatMessage(messages['account.settings.name.change.id.name.placeholder'])}
value={verifiedNameInput}
onChange={handleChange}
/>
{renderErrors()}
</Form.Group>
);
}
function renderContinueButton() {
if (!confirmedWarning) {
return (
<Button variant="primary" onClick={() => setConfirmedWarning(true)}>
{intl.formatMessage(messages['account.settings.name.change.continue'])}
</Button>
);
}
return (
<StatefulButton
type="submit"
state={saveState}
labels={{
default: intl.formatMessage(messages['account.settings.name.change.continue']),
}}
disabledStates={[]}
/>
);
}
return (
<ModalDialog
title={renderTitle()}
isOpen
hasCloseButton={false}
onClose={handleClose}
>
<Form onSubmit={handleSubmit}>
<ModalDialog.Header>
<ModalDialog.Title>
{renderTitle()}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="mb-3 overflow-hidden">
{renderBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages['account.settings.name.change.cancel'])}
</ModalDialog.CloseButton>
{renderContinueButton()}
</ActionRow>
</ModalDialog.Footer>
</Form>
</ModalDialog>
);
};
NameChangeModal.propTypes = {
targetFormId: PropTypes.string.isRequired,
errors: PropTypes.shape({}).isRequired,
formValues: PropTypes.shape({
name: PropTypes.string,
verified_name: PropTypes.string,
}).isRequired,
saveState: PropTypes.string,
intl: intlShape.isRequired,
};
NameChangeModal.defaultProps = {
saveState: null,
};
export default connect(nameChangeSelector)(injectIntl(NameChangeModal));

View File

@@ -1,25 +0,0 @@
import { AsyncActionType } from '../../data/utils';
export const REQUEST_NAME_CHANGE = new AsyncActionType('ACCOUNT_SETTINGS', 'REQUEST_NAME_CHANGE');
export const requestNameChange = (username, profileName, verifiedName) => ({
type: REQUEST_NAME_CHANGE.BASE,
payload: { username, profileName, verifiedName },
});
export const requestNameChangeBegin = () => ({
type: REQUEST_NAME_CHANGE.BEGIN,
});
export const requestNameChangeSuccess = () => ({
type: REQUEST_NAME_CHANGE.SUCCESS,
});
export const requestNameChangeFailure = errors => ({
type: REQUEST_NAME_CHANGE.FAILURE,
payload: { errors },
});
export const requestNameChangeReset = () => ({
type: REQUEST_NAME_CHANGE.RESET,
});

View File

@@ -1,44 +0,0 @@
import { REQUEST_NAME_CHANGE } from './actions';
export const defaultState = {
saveState: null,
errors: {},
};
const reducer = (state = defaultState, action = null) => {
if (action !== null) {
switch (action.type) {
case REQUEST_NAME_CHANGE.BEGIN:
return {
...state,
saveState: 'pending',
errors: {},
};
case REQUEST_NAME_CHANGE.SUCCESS:
return {
...state,
saveState: 'complete',
};
case REQUEST_NAME_CHANGE.FAILURE:
return {
...state,
saveState: 'error',
errors: action.payload.errors || { general_error: 'A technical error occurred. Please try again.' },
};
case REQUEST_NAME_CHANGE.RESET:
return {
...state,
saveState: null,
errors: {},
};
default:
}
}
return state;
};
export default reducer;

View File

@@ -1,40 +0,0 @@
import { put, call, takeEvery } from 'redux-saga/effects';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { postVerifiedName } from '../../data/service';
import {
REQUEST_NAME_CHANGE,
requestNameChangeBegin,
requestNameChangeSuccess,
requestNameChangeFailure,
} from './actions';
import { postNameChange } from './service';
export function* handleRequestNameChange(action) {
let { name: profileName } = getAuthenticatedUser();
try {
yield put(requestNameChangeBegin());
if (action.payload.profileName) {
yield call(postNameChange, action.payload.profileName);
profileName = action.payload.profileName;
}
yield call(postVerifiedName, {
username: action.payload.username,
verified_name: action.payload.verifiedName,
profile_name: profileName,
});
yield put(requestNameChangeSuccess());
} catch (err) {
if (err.customAttributes?.httpErrorResponseData) {
yield put(requestNameChangeFailure(JSON.parse(err.customAttributes.httpErrorResponseData)));
} else {
yield put(requestNameChangeFailure());
}
}
}
export default function* saga() {
yield takeEvery(REQUEST_NAME_CHANGE.BASE, handleRequestNameChange);
}

View File

@@ -1,17 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { handleRequestError } from '../../data/utils';
// eslint-disable-next-line import/prefer-default-export
export async function postNameChange(name) {
// Requests a pending name change, rather than saving the account name immediately
const requestConfig = { headers: { Accept: 'application/json' } };
const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/accounts/name_change/`;
const { data } = await getAuthenticatedHttpClient()
.post(requestUrl, { name }, requestConfig)
.catch(error => handleRequestError(error));
return data;
}

View File

@@ -1,5 +0,0 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './NameChange';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';
export { REQUEST_NAME_CHANGE } from './data/actions';

View File

@@ -1,56 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.name.change.title.id': {
id: 'account.settings.name.change.title.id',
defaultMessage: 'This name change requires identity verification',
description: 'Inform the user that changing their name requires identity verification',
},
'account.settings.name.change.title.begin': {
id: 'account.settings.name.change.title.begin',
defaultMessage: 'Before we begin',
description: 'Title before beginning the ID verification process',
},
'account.settings.name.change.warning.one': {
id: 'account.settings.name.change.warning.one',
defaultMessage: 'Warning: This action updates the name that appears on all certificates that have been earned on this account in the past and any certificates you are currently earning or will earn in the future.',
description: 'Warning informing the user that a name change will update the name on all of their certificates.',
},
'account.settings.name.change.warning.two': {
id: 'account.settings.name.change.warning.two',
defaultMessage: 'This action cannot be undone without verifying your identity.',
description: 'Warning informing the user that a name change cannot be undone without ID verification.',
},
'account.settings.name.change.id.name.label': {
id: 'account.settings.name.change.id.name.label',
defaultMessage: 'Enter your name as it appears on your identification card.',
description: 'Form label instructing the user to enter the name on their ID.',
},
'account.settings.name.change.id.name.placeholder': {
id: 'account.settings.name.change.id.name.placeholder',
defaultMessage: 'Enter the name on your photo ID',
description: 'Form label instructing the user to enter the name on their ID.',
},
'account.settings.name.change.error.valid.name': {
id: 'account.settings.name.change.error.valid.name',
defaultMessage: 'Please enter a valid name.',
description: 'Error that appears when the user doesnt enter a valid name.',
},
'account.settings.name.change.error.general': {
id: 'account.settings.name.change.error.general',
defaultMessage: 'A technical error occurred. Please try again.',
description: 'Generic error message.',
},
'account.settings.name.change.continue': {
id: 'account.settings.name.change.continue',
defaultMessage: 'Continue',
description: 'Continue button.',
},
'account.settings.name.change.cancel': {
id: 'account.settings.name.change.cancel',
defaultMessage: 'Cancel',
description: 'Cancel button.',
},
});
export default messages;

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