Compare commits

..

2 Commits

Author SHA1 Message Date
stvn
71553c98a7 build(ci): always run npm ci via Makefile 2021-06-02 01:31:41 -07:00
stvn
29c21c1b1d build(ci): convert travis-ci to github actions 2021-06-02 01:31:03 -07:00
194 changed files with 26437 additions and 29533 deletions

58
.env
View File

@@ -1,35 +1,25 @@
ACCESS_TOKEN_COOKIE_NAME=''
BASE_URL=''
CREDENTIALS_BASE_URL=''
CSRF_TOKEN_API_PATH=''
DEMOGRAPHICS_BASE_URL=''
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_DEMOGRAPHICS_COLLECTION=''
FAVICON_URL=''
LANGUAGE_PREFERENCE_COOKIE_NAME=''
LMS_BASE_URL=''
LOGIN_URL=''
LOGO_TRADEMARK_URL=''
LOGO_URL=''
LOGO_WHITE_URL=''
LOGOUT_URL=''
MARKETING_SITE_BASE_URL=''
NODE_ENV=''
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
ACCESS_TOKEN_COOKIE_NAME=null
BASE_URL=null
CREDENTIALS_BASE_URL=null
CSRF_TOKEN_API_PATH=null
ECOMMERCE_BASE_URL=null
LANGUAGE_PREFERENCE_COOKIE_NAME=null
LMS_BASE_URL=null
DEMOGRAPHICS_BASE_URL=null
LOGIN_URL=null
LOGOUT_URL=null
MARKETING_SITE_BASE_URL=null
NODE_ENV=null
ORDER_HISTORY_URL=null
REFRESH_ACCESS_TOKEN_ENDPOINT=null
SEGMENT_KEY=null
SITE_NAME=''
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://support.edx.org/hc/en-us/articles/207206067'
SUPPORT_URL=null
USER_INFO_COOKIE_NAME=null
LOGO_URL=''
LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL=''
FAVICON_URL=''
PUBLISHER_BASE_URL=
STUDIO_BASE_URL=
DISCOVERY_API_BASE_URL=

View File

@@ -2,35 +2,27 @@ 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'
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_DEMOGRAPHICS_COLLECTION=''
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'
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
NODE_ENV='development'
ORDER_HISTORY_URL='http://localhost:1996/orders'
ORDER_HISTORY_URL='localhost:1996/orders'
PORT=1997
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SEGMENT_KEY=null
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=''
APP_ID=
MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'
COACHING_ENABLED=false
ENABLE_DEMOGRAPHICS_COLLECTION=false
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
PUBLISHER_BASE_URL=
STUDIO_BASE_URL=
DISCOVERY_API_BASE_URL=

View File

@@ -2,33 +2,26 @@ 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'
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
DISCOVERY_API_BASE_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_DEMOGRAPHICS_COLLECTION=''
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=''
DEMOGRAPHICS_BASE_URL='http://localhost:18360'
NODE_ENV=null
ORDER_HISTORY_URL='localhost:1996/orders'
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SEGMENT_KEY=null
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=''
APP_ID=
MFE_CONFIG_API_URL=
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://support.edx.org/hc/en-us/articles/207206067'
COACHING_ENABLED=''
ENABLE_DEMOGRAPHICS_COLLECTION=''
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
PUBLISHER_BASE_URL=
STUDIO_BASE_URL=
DISCOVERY_API_BASE_URL=

View File

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

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @edx/community-engineering

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 +1,26 @@
name: ci
---
name: validate
on:
push:
branches:
- master
pull_request:
jobs:
tests:
build:
runs-on: ubuntu-latest
strategy:
matrix:
npm-test:
- i18n_extract
- lint
- test
node-version:
- 12
steps:
- uses: actions/checkout@v4
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v2
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: ${{ env.NODE_VER }}
node-version: ${{ matrix.node-version }}
- run: npm install -g npm@6
- run: make requirements
- run: make test NPM_TESTS=build
- run: make test NPM_TESTS=${{ matrix.npm-test }}
- run: make test
- name: upload coverage
uses: codecov/codecov-action@v3
uses: codecov/codecov-action@v1
with:
fail_ci_if_error: false
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,14 +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/lockfile-check.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 }}

1
.nvmrc
View File

@@ -1 +0,0 @@
18

View File

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

34
Makefile Normal file → Executable file
View File

@@ -1,16 +1,16 @@
export TRANSIFEX_RESOURCE = frontend-app-account
transifex_resource = frontend-app-account
transifex_langs = "ar,de,es_419,fa_IR,fr,fr_CA,hi,it,pt,ru,uk,zh_CN,it_IT,pt_PT,de_DE"
transifex_langs = "ar,fr,es_419,zh_CN"
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
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test
NPM_TESTS=i18n_extract lint test build is-es5
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
@@ -45,31 +45,15 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
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
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-account
endif
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,19 +1,18 @@
####################
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
frontend-app-account
####################
====================
|ci-badge| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
Please tag **@edx/community-engineering** on any PRs or issues. Thanks!
********
Purpose
********
Introduction
------------
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>`_
In this MFE: Private user settings UIs. Public facing profile is in a `separate MFE (Profile) <https://github.com/edx/frontend-app-profile>`_
- Account settings page
@@ -21,32 +20,12 @@ In this MFE: Private user settings UIs. Public facing profile is in a `separate
- IDV (Identity Verification)
***************
Getting Started
***************
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Installation
============
------------
This MFE is bundled with `Devstack <https://github.com/openedx/devstack>`_, see the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ section for setup instructions.
This MFE is bundled with `Devstack <https://github.com/edx/devstack>`_, see the `Getting Started <https://github.com/edx/devstack#getting-started>`_ section for setup instructions.
1. Install Devstack using the `Getting Started <https://github.com/openedx/devstack#getting-started>`_ instructions.
1. Install Devstack using the `Getting Started <https://github.com/edx/devstack#getting-started>`_ instructions.
2. Start up Devstack, if it's not already started.
@@ -64,7 +43,7 @@ This MFE is bundled with `Devstack <https://github.com/openedx/devstack>`_, see
.. image:: ./docs/images/localhost_preview.png
Environment Variables/Setup Notes
=================================
---------------------------------
This MFE is configured via environment variables supplied at build time. All micro-frontends have a shared set of required environment variables, as documented in the Open edX Developer Guide under `Required Environment Variables <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
@@ -76,28 +55,17 @@ 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.
edX-specific Environment Variables
==================================
**********************************
Furthermore, there are several edX-specific environment variables that enable integrations with closed-source services private to the edX organization, and are unsupported in Open edX. Enabling these environment variables will result in undefined behavior in Open edX installations:
``COACHING_ENABLED``
Example: ``true`` | ``''`` (empty strings are falsy)
Enables support for a section of the micro-frontend that helps users arrange for coaching sessions. Integrates with a private coaching plugin and is only used by edx.org.
``ENABLE_DEMOGRAPHICS_COLLECTION``
Example: ``true`` | ``''`` (empty strings are falsy)
@@ -119,107 +87,23 @@ Example build syntax with a single environment variable:
For more information see the document: `Micro-frontend applications in Open
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#required-environment-variables>`__.
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``
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.
In the future, it's possible that demographics could be modeled as a plugin rather than being hard-coded into this MFE.
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/
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
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-app-account.svg?branch=master
:target: https://travis-ci.com/edx/frontend-app-account
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
:target: https://codecov.io/gh/edx/frontend-app-account
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg

View File

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

View File

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

37663
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,49 +6,50 @@
"license": "AGPL-3.0",
"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",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-account/issues"
"url": "https://github.com/edx/frontend-app-account/issues"
},
"homepage": "https://github.com/openedx/frontend-app-account#readme",
"homepage": "https://github.com/edx/frontend-app-account#readme",
"publishConfig": {
"access": "public"
},
"browserslist": [
"extends @edx/browserslist-config"
"last 2 versions",
"ie 11"
],
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "13.0.2",
"@edx/frontend-component-header": "5.0.2",
"@edx/frontend-platform": "7.1.0",
"@edx/openedx-atlas": "^0.6.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0",
"@openedx/paragon": "22.0.0",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.4",
"@edx/frontend-component-header": "2.2.4",
"@edx/frontend-platform": "1.9.5",
"@edx/paragon": "13.1.2",
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-brands-svg-icons": "5.8.2",
"@fortawesome/free-regular-svg-icons": "5.7.2",
"@fortawesome/free-solid-svg-icons": "5.8.2",
"@fortawesome/react-fontawesome": "0.1.14",
"@tensorflow-models/blazeface": "0.0.7",
"@tensorflow/tfjs-converter": "3.21.0",
"@tensorflow/tfjs-core": "3.21.0",
"bowser": "2.11.0",
"classnames": "2.5.1",
"core-js": "3.35.1",
"@tensorflow/tfjs-converter": "1.6.1",
"@tensorflow/tfjs-core": "1.6.1",
"babel-polyfill": "6.26.0",
"bowser": "2.10.0",
"classnames": "2.2.6",
"font-awesome": "4.7.0",
"form-urlencoded": "6.1.4",
"formdata-polyfill": "4.0.10",
"jslib-html5-camera-photo": "3.3.4",
"lodash.camelcase": "4.3.0",
"form-urlencoded": "4.0.1",
"formdata-polyfill": "3.0.20",
"history": "4.10.1",
"jslib-html5-camera-photo": "3.1.6",
"lodash.debounce": "4.0.8",
"lodash.findindex": "4.6.0",
"lodash.get": "4.4.2",
@@ -57,36 +58,36 @@
"lodash.omit": "4.5.0",
"lodash.pick": "4.4.0",
"lodash.pickby": "4.6.0",
"lodash.snakecase": "4.1.1",
"long": "5.2.3",
"memoize-one": "5.2.1",
"prop-types": "15.8.1",
"qs": "6.11.2",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.22.0",
"react-router-dom": "6.22.0",
"react-router-hash-link": "2.4.3",
"memoize-one": "5.1.1",
"newrelic": "5.13.1",
"prop-types": "15.7.2",
"qs": "6.9.6",
"react": "16.10.2",
"react-dom": "16.10.2",
"react-redux": "7.1.3",
"react-router": "5.1.2",
"react-router-dom": "5.1.2",
"react-router-hash-link": "1.2.2",
"react-scrollspy": "3.4.3",
"react-transition-group": "4.4.5",
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"react-transition-group": "4.3.0",
"redux": "4.0.5",
"redux-devtools-extension": "2.13.8",
"redux-logger": "3.0.6",
"redux-saga": "1.3.0",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "4.1.8",
"redux-saga": "1.1.3",
"redux-thunk": "2.3.0",
"reselect": "4.0.0",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/reactifex": "1.1.0",
"@openedx/frontend-build": "13.0.27",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
"react-test-renderer": "17.0.2",
"@edx/frontend-build": "5.6.9",
"@testing-library/jest-dom": "5.11.9",
"@testing-library/react": "10.4.9",
"codecov": "3.7.2",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.6",
"es-check": "5.0.0",
"husky": "3.0.9",
"react-test-renderer": "16.8.6",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.4"
}

View File

@@ -1,148 +1,17 @@
<!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 | <%= 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="<%=webpackConfig.output.publicPath%>favicon.ico" 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>
<% } %>
</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 +1,15 @@
{
"extends": [
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
":automergeMinor",
":noUnscheduledUpdates",
":semanticCommits"
],
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
"rebaseStalePrs": true,
"schedule": [
"every weekend"
],
"timezone": "America/New_York"
}

View File

@@ -1,5 +1,5 @@
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig, getQueryParameters } from '@edx/frontend-platform';
import { getConfig, history, getQueryParameters } from '@edx/frontend-platform';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
@@ -13,51 +13,43 @@ import {
getCountryList,
getLanguageList,
} from '@edx/frontend-platform/i18n';
import {
Button, Hyperlink, Icon, Alert,
} from '@openedx/paragon';
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
import { Hyperlink } from '@edx/paragon';
import messages from './AccountSettingsPage.messages';
import {
fetchSettings,
saveMultipleSettings,
saveSettings,
updateDraft,
beginNameChange,
} from './data/actions';
import { fetchSettings, saveSettings, updateDraft } from './data/actions';
import { accountSettingsPageSelector } from './data/selectors';
import PageLoading from './PageLoading';
import Alert from './Alert';
import JumpNav from './JumpNav';
import DeleteAccount from './delete-account';
import EditableField from './EditableField';
import 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 {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
GENDER_OPTIONS,
COUNTRY_WITH_STATES,
COPPA_COMPLIANCE_YEAR,
WORK_EXPERIENCE_OPTIONS,
getStatesList,
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import CoachingToggle from './coaching/CoachingToggle';
import DemographicsSection from './demographics/DemographicsSection';
import { fetchCourseList } from '../notification-preferences/data/thunks';
import { withLocation, withNavigate } from './hoc';
class AccountSettingsPage extends React.Component {
constructor(props, context) {
super(props, context);
// If there is a "duplicate_provider" query parameter, that's the backend's
// way of telling us that the provider account the user tried to link is already linked
// to another user account on the platform. We use this to display a message to that effect,
// and remove the parameter from the URL.
const duplicateTpaProvider = getQueryParameters().duplicate_provider;
if (duplicateTpaProvider !== undefined) {
history.replace(history.location.pathname);
}
this.state = {
duplicateTpaProvider,
};
@@ -74,9 +66,8 @@ class AccountSettingsPage extends React.Component {
}
componentDidMount() {
this.props.fetchCourseList();
this.props.fetchSettings();
this.props.fetchSiteLanguages(this.props.navigate);
this.props.fetchSiteLanguages();
sendTrackingLogEvent('edx.user.settings.viewed', {
page: 'account',
visibility: null,
@@ -143,10 +134,6 @@ class AccountSettingsPage extends React.Component {
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,
})),
}));
handleEditableFieldChange = (name, value) => {
@@ -154,45 +141,7 @@ class AccountSettingsPage extends React.Component {
};
handleSubmit = (formId, values) => {
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);
}
this.props.saveSettings(formId, values);
};
isEditable(fieldName) {
@@ -210,15 +159,9 @@ class AccountSettingsPage extends React.Component {
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">
<Alert className="alert alert-danger" role="alert">
<FormattedMessage
id="account.settings.message.duplicate.tpa.provider"
defaultMessage="The {provider} account you selected is already linked to another {siteName} account."
@@ -240,7 +183,7 @@ class AccountSettingsPage extends React.Component {
return (
<div>
<Alert variant="info">
<Alert className="alert alert-primary" role="alert">
<FormattedMessage
id="account.settings.message.managed.settings"
defaultMessage="Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help."
@@ -263,170 +206,6 @@ class AccountSettingsPage extends React.Component {
);
}
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>
<div className="d-flex flex-row-reverse mt-3">
<Button
variant="primary"
href="https://support.edx.org/hc/en-us/articles/360004381594-Why-was-my-ID-verification-denied"
>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.help.link'])}
</Button>{' '}
</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'], {
@@ -436,13 +215,6 @@ class AccountSettingsPage extends React.Component {
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;
@@ -484,14 +256,10 @@ class AccountSettingsPage extends React.Component {
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 showState = this.props.formValues.country === COUNTRY_WITH_STATES;
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,
@@ -501,45 +269,15 @@ class AccountSettingsPage extends React.Component {
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=""
/>
)}
<h2 className="section-heading h4 mb-3">
<div className="account-section" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
{this.renderManagedProfileMessage()}
{this.renderNameChangeModal()}
<EditableField
name="username"
type="text"
@@ -555,58 +293,17 @@ class AccountSettingsPage extends React.Component {
<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
}
value={this.props.formValues.name}
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
this.isEditable('name')
? this.props.intl.formatMessage(messages['account.settings.field.full.name.empty'])
: this.renderEmptyStaticFieldMessage()
}
helpText={
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}
helpText={this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])}
isEditable={this.isEditable('name')}
{...editableFieldProps}
/>
{verifiedName
&& (
<EditableField
name="verified_name"
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}
/>
)}
<EmailField
name="email"
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
@@ -626,19 +323,16 @@ class AccountSettingsPage extends React.Component {
/>
{this.renderSecondaryEmailField(editableFieldProps)}
<ResetPassword email={this.props.formValues.email} />
{(!getConfig().ENABLE_COPPA_COMPLIANCE)
&& (
<EditableSelectField
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}
/>
)}
<EditableSelectField
<EditableField
name="year_of_birth"
type="select"
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={this.props.formValues.year_of_birth}
options={yearOfBirthOptions}
{...editableFieldProps}
/>
<EditableField
name="country"
type="select"
value={this.props.formValues.country}
@@ -654,7 +348,7 @@ class AccountSettingsPage extends React.Component {
/>
{showState
&& (
<EditableSelectField
<EditableField
name="state"
type="select"
value={this.props.formValues.state}
@@ -671,23 +365,21 @@ class AccountSettingsPage extends React.Component {
)}
</div>
<div className="account-section pt-3 mb-5" id="profile-information" ref={this.navLinkRefs['#profile-information']}>
<h2 className="section-heading h4 mb-3">
<div className="account-section" id="profile-information" ref={this.navLinkRefs['#profile-information']}>
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.profile.information'])}
</h2>
<EditableSelectField
<EditableField
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}
options={educationLevelOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.education.empty'])}
{...editableFieldProps}
/>
<EditableSelectField
<EditableField
name="gender"
type="select"
value={this.props.formValues.gender}
@@ -696,19 +388,7 @@ class AccountSettingsPage extends React.Component {
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
<EditableField
name="language_proficiencies"
type="select"
value={this.props.formValues.language_proficiencies}
@@ -717,10 +397,19 @@ class AccountSettingsPage extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
{...editableFieldProps}
/>
{getConfig().COACHING_ENABLED
&& this.props.formValues.coaching.eligible_for_coaching
&& (
<CoachingToggle
name="coaching"
phone_number={this.props.formValues.phone_number}
coaching={this.props.formValues.coaching}
/>
)}
</div>
{getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && this.renderDemographicsSection()}
<div className="account-section pt-3 mb-5" id="social-media">
<h2 className="section-heading h4 mb-3">
<div className="account-section" id="social-media">
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
</h2>
<p>
@@ -756,13 +445,13 @@ class AccountSettingsPage extends React.Component {
/>
</div>
<div className="account-section pt-3 mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<h2 className="section-heading h4 mb-3">
<div className="account-section" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
</h2>
<BetaLanguageBanner />
<EditableSelectField
<EditableField
name="siteLanguage"
type="select"
options={this.props.siteLanguageOptions}
@@ -771,7 +460,7 @@ class AccountSettingsPage extends React.Component {
helpText={this.props.intl.formatMessage(messages['account.settings.field.site.language.help.text'])}
{...editableFieldProps}
/>
<EditableSelectField
<EditableField
name="time_zone"
type="select"
value={this.props.formValues.time_zone}
@@ -787,8 +476,8 @@ class AccountSettingsPage extends React.Component {
/>
</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>
<div className="account-section" id="linked-accounts" ref={this.navLinkRefs['#linked-accounts']}>
<h2 className="section-heading">{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts'])}</h2>
<p>
{this.props.intl.formatMessage(
messages['account.settings.section.linked.accounts.description'],
@@ -798,15 +487,12 @@ class AccountSettingsPage extends React.Component {
<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}
/>
</div>
)}
<div className="account-section" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
<DeleteAccount
isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA}
/>
</div>
</>
);
@@ -843,12 +529,12 @@ class AccountSettingsPage extends React.Component {
</h1>
<div>
<div className="row">
<div className="col-md-2">
<div className="col-md-3">
<JumpNav
displayDemographicsLink={this.props.formValues.shouldDisplayDemographicsSection}
/>
</div>
<div className="col-md-10">
<div className="col-md-9">
{loading ? this.renderLoading() : null}
{loaded ? this.renderContent() : null}
{loadingError ? this.renderError() : null}
@@ -879,28 +565,20 @@ AccountSettingsPage.propTypes = {
country: PropTypes.string,
level_of_education: PropTypes.string,
gender: PropTypes.string,
extended_profile: 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,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
}),
state: PropTypes.string,
shouldDisplayDemographicsSection: PropTypes.bool,
useVerifiedNameForCerts: PropTypes.bool.isRequired,
verified_name: PropTypes.string,
}).isRequired,
committedValues: PropTypes.shape({
name: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string,
}),
drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({
name: PropTypes.string,
}),
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
draft: PropTypes.string,
@@ -924,48 +602,15 @@ AccountSettingsPage.propTypes = {
})),
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.shape({
formId: PropTypes.string,
}),
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,
tpaProviders: PropTypes.arrayOf(PropTypes.object),
};
AccountSettingsPage.defaultProps = {
loading: false,
loaded: false,
loadingError: null,
committedValues: {
useVerifiedNameForCerts: false,
verified_name: null,
},
drafts: {},
formErrors: {},
siteLanguage: null,
siteLanguageOptions: [],
timeZoneOptions: [],
@@ -975,18 +620,11 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {},
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
};
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
fetchCourseList,
export default connect(accountSettingsPageSelector, {
fetchSettings,
saveSettings,
saveMultipleSettings,
updateDraft,
fetchSiteLanguages,
beginNameChange,
})(injectIntl(AccountSettingsPage))));
})(injectIntl(AccountSettingsPage));

View File

@@ -91,126 +91,6 @@ const messages = defineMessages({
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.name.verified.verification.alert': {
id: 'account.settings.field.name.verified.verification.help',
defaultMessage: 'Enter your name as it appears on your unexpired student, work, or government-issued identification card.',
description: 'Form label instructing the user to enter the name on their ID.',
},
'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)',
@@ -266,56 +146,6 @@ const messages = defineMessages({
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',
@@ -411,8 +241,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.',
},
@@ -565,26 +395,6 @@ const messages = defineMessages({
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

@@ -2,16 +2,18 @@ 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,

View File

@@ -3,7 +3,7 @@ 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 { Button, Hyperlink } from '@edx/paragon';
import { betaLanguageBannerSelector } from './data/selectors';
import messages from './AccountSettingsPage.messages';
@@ -49,9 +49,6 @@ class BetaLanguageBanner extends React.Component {
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) {

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,11 +1,10 @@
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';
Button, Input, StatefulButton, ValidationFormGroup,
} from '@edx/paragon';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -17,9 +16,8 @@ import {
closeForm,
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
import CertificatePreference from './certificate-preference/CertificatePreference';
const EditableField = (props) => {
function EditableField(props) {
const {
name,
label,
@@ -27,6 +25,7 @@ const EditableField = (props) => {
type,
value,
userSuppliedValue,
options,
saveState,
error,
confirmationMessageDefinition,
@@ -38,7 +37,6 @@ const EditableField = (props) => {
onChange,
isEditing,
isEditable,
isGrayedOut,
intl,
...others
} = props;
@@ -74,6 +72,15 @@ const EditableField = (props) => {
}
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}`;
}
@@ -95,56 +102,54 @@ const EditableField = (props) => {
expression={isEditing ? 'editing' : 'default'}
cases={{
editing: (
<>
<form onSubmit={handleSubmit}>
<Form.Group
controlId={id}
isInvalid={error != null}
<form onSubmit={handleSubmit}>
<ValidationFormGroup
for={id}
invalid={error != null}
invalidMessage={error}
helpText={helpText}
>
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
data-hj-suppress
name={name}
id={id}
type={type}
value={value}
onChange={handleChange}
options={options}
{...others}
/>
<>{others.children}</>
</ValidationFormGroup>
<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}
>
<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} />}
</>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
),
default: (
<div className="form-group">
@@ -156,22 +161,26 @@ const EditableField = (props) => {
</Button>
) : null}
</div>
<p data-hj-suppress className={classNames('text-truncate', { 'grayed-out': isGrayedOut })}>{renderValue(value)}</p>
<p data-hj-suppress>{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]),
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
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({
@@ -187,12 +196,12 @@ EditableField.propTypes = {
onChange: PropTypes.func.isRequired,
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
isGrayedOut: PropTypes.bool,
intl: intlShape.isRequired,
};
EditableField.defaultProps = {
value: undefined,
options: undefined,
saveState: undefined,
label: undefined,
emptyLabel: undefined,
@@ -202,7 +211,6 @@ EditableField.defaultProps = {
helpText: undefined,
isEditing: false,
isEditable: true,
isGrayedOut: false,
userSuppliedValue: undefined,
};

View File

@@ -1,251 +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}`}
>
{subOption.label}
</option>
))}
</optgroup>
);
}
return (
<option value={option.value} key={`${option.value}-${option.label}`}>
{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

@@ -3,8 +3,8 @@ 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';
Button, StatefulButton, Input, ValidationFormGroup,
} from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
@@ -18,7 +18,7 @@ import {
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
const EmailField = (props) => {
function EmailField(props) {
const {
name,
label,
@@ -106,12 +106,14 @@ const EmailField = (props) => {
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
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
data-hj-suppress
name={name}
id={id}
@@ -119,9 +121,7 @@ const EmailField = (props) => {
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"
@@ -169,7 +169,7 @@ const EmailField = (props) => {
}}
/>
);
};
}
EmailField.propTypes = {
name: PropTypes.string.isRequired,

View File

@@ -1,26 +1,15 @@
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize, Icon } from '@openedx/paragon';
import { OpenInNew } from '@openedx/paragon/icons';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import { Link } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import messages from './AccountSettingsPage.messages';
import { selectShowPreferences } from '../notification-preferences/data/selectors';
const JumpNav = ({
intl,
displayDemographicsLink,
}) => {
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
const showPreferences = useSelector(selectShowPreferences());
function JumpNav({ intl, displayDemographicsLink }) {
return (
<div className={classNames('jump-nav px-2.25', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
<div className="jump-nav">
<Scrollspy
items={[
'basic-information',
@@ -67,33 +56,15 @@ const JumpNav = ({
{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>
)}
<li>
<NavHashLink to="#delete-account">
{intl.formatMessage(messages['account.settings.jump.nav.delete.account'])}
</NavHashLink>
</li>
</Scrollspy>
{showPreferences && (
<>
<hr />
<Scrollspy
className="list-unstyled"
>
<li>
<Link to="/notifications" target="_blank" rel="noopener noreferrer">
<span>{intl.formatMessage(messages['notification.preferences.notifications.label'])}</span>
<Icon className="d-inline-block align-bottom ml-1" src={OpenInNew} />
</Link>
</li>
</Scrollspy>
</>
)}
</div>
);
};
}
JumpNav.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,19 +1,16 @@
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;
export default function NotFoundPage() {
return (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
<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>
);
}

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

@@ -1,6 +1,6 @@
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
@@ -22,7 +22,7 @@ const onChildExit = (htmlNode) => {
}
};
const SwitchContent = ({ expression, cases, className }) => {
function SwitchContent({ expression, cases, className }) {
const getContent = (caseKey) => {
if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') {
@@ -48,7 +48,7 @@ const SwitchContent = ({ expression, cases, className }) => {
{getContent(expression)}
</TransitionReplace>
);
};
}
SwitchContent.propTypes = {
expression: PropTypes.string,

View File

@@ -14,11 +14,12 @@
display: inline-block;
}
.jump-nav-sm {
top: 1rem;
}
.jump-nav {
@media (min-width: map-get($grid-breakpoints, "sm")) {
padding-top: 1rem;
position: sticky;
top: 1rem;
}
li {
margin-bottom: .5rem;
@@ -29,6 +30,16 @@
}
}
.section-heading {
@extend .h4;
margin-bottom: map-get($spacers, 3);
}
.account-section {
// These properties together will shift the hashlink position
margin-bottom: map-get($spacers, 5);
padding-top: 1rem;
}
.custom-switch {
padding: 0;
@@ -39,8 +50,4 @@
line-height: 1.6rem;
}
}
.grayed-out{
opacity: 0.6; /* Real browsers */
filter: alpha(opacity = 60); /* MSIE */
}
}

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,171 +0,0 @@
/* eslint-disable no-import-assign */
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
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 ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
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`] = `
Object {
"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

@@ -0,0 +1,269 @@
import React from 'react';
import { getConfig, getQueryParameters } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { faCheck } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import get from 'lodash.get';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import PageLoading from '../PageLoading';
import CoachingConsentForm from './CoachingConsentForm';
import messages from './CoachingConsent.messages';
import LogoSVG from '../../logo.svg';
import { fetchSettings } from '../data/actions';
import { coachingConsentPageSelector } from '../data/selectors';
const Logo = ({ src, alt, ...attributes }) => (
<>
<img src={src} alt={alt} {...attributes} />
</>
);
const SuccessMessage = props => (
<div className="col-12 col-lg-6 shadow-lg mx-auto mt-4 p-5">
<FontAwesomeIcon className="text-success" icon={faCheck} size="5x" />
<div className="h3">{props.header}</div>
<div>{props.message}</div>
<Hyperlink destination={props.continueUrl} className="d-block p-2 my-3 text-center text-white bg-primary rounded">
{props.continue}
</Hyperlink>
</div>
);
const AutoRedirect = (props) => {
window.location.href = props.redirectUrl;
return <></>;
};
const VIEWS = {
NOT_LOADED: 'NOT_LOADED',
LOADED: 'LOADED',
SUCCESS: 'SUCCESS',
SUCCESS_PENDING: 'SUCCESS_PENDING',
DECLINED: 'DECLINED',
DECLINE_PENDING: 'DECLINE_PENDING',
};
class CoachingConsent extends React.Component {
constructor(props, context) {
super(props, context);
// Used to redirect back to the courseware.
const nextUrl = this.sanitizeForwardingUrl(getQueryParameters().next);
this.state = {
redirectUrl: nextUrl || `${getConfig().LMS_BASE_URL}/dashboard/`,
formErrors: {},
formSubmitted: false,
declineSubmitted: false,
submissionSuccess: false,
};
this.handleSubmit = this.handleSubmit.bind(this);
this.declineCoaching = this.declineCoaching.bind(this);
this.patchUsingCoachingConsentForm = this.patchUsingCoachingConsentForm.bind(this);
}
componentDidMount() {
this.props.fetchSettings();
}
sanitizeForwardingUrl(url) {
// Redirect to root of MFE if invalid next param is sent
return url && url.startsWith(getConfig().LMS_BASE_URL) ? url : `${getConfig().LMS_BASE_URL}/dashboard/`;
}
async patchUsingCoachingConsentForm(body) {
const { userId } = getAuthenticatedUser();
const requestUrl = `${getConfig().LMS_BASE_URL}/api/coaching/v1/coaching_consent/${userId}/`;
let formErrors = {};
const data = await getAuthenticatedHttpClient()
.patch(requestUrl, body)
.catch((error) => {
if (get(error, 'customAttributes.httpErrorResponseData')) {
formErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
} else {
formErrors = { full_name: 'Something went wrong. Please try again.' };
}
this.setState({
submissionSuccess: false,
formErrors,
formSubmitted: false,
});
});
if (get(data, 'status') === 200) {
this.setState({ submissionSuccess: true });
}
}
handleSubmit(e) {
e.preventDefault();
const fullName = e.target.fullName.value;
const phoneNumber = e.target.phoneNumber.value;
const body = {
coaching_consent: true,
consent_form_seen: true,
phone_number: phoneNumber,
full_name: fullName,
};
this.setState({
formErrors: {},
formSubmitted: true,
declineSubmitted: false,
}, () => this.patchUsingCoachingConsentForm(body));
}
declineCoaching(e) {
e.preventDefault();
const body = {
coaching_consent: false,
consent_form_seen: true,
};
this.setState({
formErrors: {},
formSubmitted: false,
declineSubmitted: true,
}, () => this.patchUsingCoachingConsentForm(body));
}
renderView(currentView) {
switch (currentView) {
case VIEWS.NOT_LOADED:
return <PageLoading srMessage="" />;
case VIEWS.LOADED:
return (
<CoachingConsentForm
onSubmit={this.handleSubmit}
declineCoaching={this.declineCoaching}
formErrors={this.state.formErrors}
formValues={this.props.formValues}
redirectUrl={this.state.redirectUrl}
profileDataManager={this.props.profileDataManager}
/>
);
case VIEWS.SUCCESS_PENDING:
return <PageLoading srMessage="Submitting..." />;
case VIEWS.SUCCESS:
return (
<SuccessMessage
continueUrl={this.state.redirectUrl}
header={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.header'])}
message={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.message'])}
continue={this.props.intl.formatMessage(messages['account.settings.coaching.consent.success.continue'])}
/>
);
case VIEWS.DECLINE_PENDING:
return <PageLoading srMessage="Redirecting..." />;
case VIEWS.DECLINED:
return <AutoRedirect redirectUrl={this.state.redirectUrl} />;
default:
return <></>;
}
}
render() {
const { loaded } = this.props;
const formHasErrors = Object.keys(this.state.formErrors).length > 0;
let currentView = null;
// This amount of logic was making the template very hard to read, so I broke it out into views.
if (!loaded) {
currentView = VIEWS.NOT_LOADED;
} else if (this.state.formSubmitted && !formHasErrors) {
if (this.state.submissionSuccess) {
currentView = VIEWS.SUCCESS;
} else {
currentView = VIEWS.SUCCESS_PENDING;
}
} else if (this.state.declineSubmitted && !formHasErrors) {
if (this.state.submissionSuccess) {
currentView = VIEWS.DECLINED;
} else {
currentView = VIEWS.DECLINE_PENDING;
}
} else {
currentView = VIEWS.LOADED;
}
return (
<main>
<div className="w-100 d-flex justify-content-center align-items-center shadow coaching-header">
<Logo
className="logo"
src={LogoSVG}
alt="Logo"
/>
</div>
{this.renderView(currentView)}
</main>
);
}
}
Logo.defaultProps = {
src: '',
alt: '',
};
Logo.propTypes = {
src: PropTypes.string,
alt: PropTypes.string,
};
SuccessMessage.defaultProps = {
header: '',
message: '',
continueUrl: '',
continue: '',
};
SuccessMessage.propTypes = {
header: PropTypes.string,
message: PropTypes.string,
continueUrl: PropTypes.string,
continue: PropTypes.string,
};
AutoRedirect.defaultProps = {
redirectUrl: '',
};
AutoRedirect.propTypes = {
redirectUrl: PropTypes.string,
};
CoachingConsent.defaultProps = {
loaded: false,
profileDataManager: null,
};
CoachingConsent.propTypes = {
intl: intlShape.isRequired,
loaded: PropTypes.bool,
formValues: PropTypes.shape({
name: PropTypes.string,
phone_number: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
consent_form_seen: PropTypes.bool.isRequired,
}),
}).isRequired,
formErrors: PropTypes.shape({
coaching: PropTypes.object,
}).isRequired,
confirmationValues: PropTypes.shape({
coaching: PropTypes.object,
name: PropTypes.object,
phone_number: PropTypes.object,
}).isRequired,
fetchSettings: PropTypes.func.isRequired,
profileDataManager: PropTypes.string,
};
export default connect(coachingConsentPageSelector, {
fetchSettings,
})(injectIntl(CoachingConsent));

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import get from 'lodash.get';
/**
* get all settings related to the coaching plugin. Settings used
* by Microbachelors students.
* @param {Number} userId users are identified in the api by LMS id
*/
export async function getCoachingPreferences(userId) {
let data = {};
try {
({ data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`));
} catch (error) {
// If a user isn't active the API call will fail with a lack of credentials.
data = {
coaching_consent: false,
user: userId,
eligible_for_coaching: false,
consent_form_seen: false,
};
}
return data;
}
/**
* patch all of the settings related to coaching.
* @param {Number} userId users are identified in the api by LMS id
* @param {Object} commitValues { coaching }
*/
export async function patchCoachingPreferences(userId, commitValues) {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/coaching/v1/users/${userId}/`;
const { coaching } = commitValues;
coaching.user = userId;
await getAuthenticatedHttpClient()
.patch(requestUrl, coaching)
.catch((error) => {
const apiError = Object.create(error);
apiError.fieldErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
if (get(apiError, 'fieldErrors.phone_number')) {
// eslint-disable-next-line prefer-destructuring
apiError.fieldErrors.coaching = apiError.fieldErrors.phone_number[0];
delete apiError.fieldErrors.phone_number;
}
throw apiError;
});
return commitValues;
}

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import { act } from 'react-dom/test-utils';
import configureStore from 'redux-mock-store';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import * as auth from '@edx/frontend-platform/auth';
import CoachingConsent from '../CoachingConsent';
import * as selectors from '../../data/selectors';
jest.mock('@edx/frontend-platform/auth');
const IntlCoachingConsent = injectIntl(CoachingConsent);
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ coachingConsentPageSelector: () => ({}) })));
const mockStore = configureStore();
describe('CoachingConsent', () => {
let props = {};
let store = {};
selectors.mockClear();
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
beforeEach(() => {
store = mockStore();
props = {
fetchSettings: jest.fn(),
loaded: true,
saveState: undefined,
formValues: {
name: 'edx edx',
phone_number: '1234567890',
coaching: {
coaching_consent: true,
consent_form_seen: false,
eligible_for_coaching: true,
user: 1,
},
},
formErrors: {},
confirmationValues: {},
profileDataManager: '',
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
patch: async () => ({
data: { status: 200 },
catch: () => {},
}),
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3 }));
});
it('should render', () => {
const wrapper = renderer.create(reduxWrapper(<IntlCoachingConsent {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('disables name field on enterprise user', () => {
props = {
...props,
profileDataManager: 'test person',
};
const wrapper = renderer.create(reduxWrapper(<IntlCoachingConsent {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('display completed box when successfully submitted', async () => {
const fakeEvent = {
preventDefault: () => {},
target: {
fullName: { value: 'edx edx' },
phoneNumber: { value: '9783028731' },
},
};
const wrapper = renderer.create(
reduxWrapper(<IntlCoachingConsent {...props} />),
{
// bypass the forward-ref. we don't care about focus for this one test
createNodeMock: (element) => {
if (element.type === 'button') {
// mock a focus function
return {
focus: async () => wrapper.root.findByType('form').props.onSubmit(fakeEvent),
};
}
return null;
},
},
);
const form = wrapper.root.findByType('form');
await act(async () => { await form.props.onSubmit(fakeEvent); });
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,278 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CoachingConsent disables name field on enterprise user 1`] = `
<main>
<div
className="w-100 d-flex justify-content-center align-items-center shadow coaching-header"
>
<img
alt="Logo"
className="logo"
src="icon/mock/path"
/>
</div>
<div
className="col-12 col-md-6 col-xl-5 mx-auto mt-4 p-5 shadow-lg"
>
<h2
className="h2"
>
Lets get started.
</h2>
<p>
MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*
</p>
<div>
<form
onSubmit={[Function]}
>
<div
className="py-3"
>
<div
className="alert d-flex align-items-start alert alert-primary"
>
<div />
<div>
<span>
Your name is managed by
<b>
test person
</b>
. Contact your administrator for help.
</span>
</div>
</div>
<div
className="alert-warning mb-2"
>
</div>
<label
className="h6"
htmlFor="fullName"
>
Please confirm your name
</label>
<input
className="form-control"
defaultValue="edx edx"
disabled={true}
id="fullName"
name="full-name"
type="text"
/>
</div>
<div
className="py-3"
>
<div
className="alert-warning mb-2"
>
</div>
<label
className="h6"
htmlFor="phoneNumber"
>
Enter your mobile number
</label>
<input
className="form-control"
defaultValue="1234567890"
id="phoneNumber"
name="phone_number"
type="text"
/>
</div>
<div
className=" py-3"
>
<p
className="small font-italic"
>
* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.
</p>
</div>
<div
className="alert-warning mb-2"
>
</div>
<div
className="d-flex flex-column align-items-center"
>
<button
className="w-100 btn btn-outline-primary"
disabled={false}
type="submit"
>
Sign up for coaching
</button>
</div>
<div
className="mt-3"
>
<a
className="mt-3 text-dark btn-link small"
href="http://localhost:18000/dashboard/"
onClick={[Function]}
target="_self"
>
I prefer not to be contacted with free coaching services
</a>
</div>
</form>
</div>
</div>
</main>
`;
exports[`CoachingConsent display completed box when successfully submitted 1`] = `
<main>
<div
className="w-100 d-flex justify-content-center align-items-center shadow coaching-header"
>
<img
alt="Logo"
className="logo"
src="icon/mock/path"
/>
</div>
<div>
<div
className="d-flex justify-content-center align-items-center flex-column"
style={
Object {
"height": "50vh",
}
}
>
<div
className="spinner-border text-primary"
role="status"
>
<span
className="sr-only"
>
Submitting...
</span>
</div>
</div>
</div>
</main>
`;
exports[`CoachingConsent should render 1`] = `
<main>
<div
className="w-100 d-flex justify-content-center align-items-center shadow coaching-header"
>
<img
alt="Logo"
className="logo"
src="icon/mock/path"
/>
</div>
<div
className="col-12 col-md-6 col-xl-5 mx-auto mt-4 p-5 shadow-lg"
>
<h2
className="h2"
>
Lets get started.
</h2>
<p>
MicroBachelors programs include coaching that focuses on your career, education, and how you'll achieve results through one-on-one communication with an experienced professional. If youre interested, provide the information below and click “Submit,” and our coaching partner will connect with you via email and/or text message to help you move forward. Terms and conditions apply.*
</p>
<div>
<form
onSubmit={[Function]}
>
<div
className="py-3"
>
<div
className="alert-warning mb-2"
>
</div>
<label
className="h6"
htmlFor="fullName"
>
Please confirm your name
</label>
<input
className="form-control"
defaultValue="edx edx"
disabled={false}
id="fullName"
name="full-name"
type="text"
/>
</div>
<div
className="py-3"
>
<div
className="alert-warning mb-2"
>
</div>
<label
className="h6"
htmlFor="phoneNumber"
>
Enter your mobile number
</label>
<input
className="form-control"
defaultValue="1234567890"
id="phoneNumber"
name="phone_number"
type="text"
/>
</div>
<div
className=" py-3"
>
<p
className="small font-italic"
>
* Coaching services are included at no additional cost to learners with US phone numbers. Coaching includes recurring text messages. Message and data rates may apply. Text STOP to opt-out.
</p>
</div>
<div
className="alert-warning mb-2"
>
</div>
<div
className="d-flex flex-column align-items-center"
>
<button
className="w-100 btn btn-outline-primary"
disabled={false}
type="submit"
>
Sign up for coaching
</button>
</div>
<div
className="mt-3"
>
<a
className="mt-3 text-dark btn-link small"
href="http://localhost:18000/dashboard/"
onClick={[Function]}
target="_self"
>
I prefer not to be contacted with free coaching services
</a>
</div>
</form>
</div>
</div>
</main>
`;

View File

@@ -9,7 +9,6 @@ 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
@@ -26,7 +25,6 @@ export const fetchSettingsSuccess = ({
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
}) => ({
type: FETCH_SETTINGS.SUCCESS,
payload: {
@@ -34,7 +32,6 @@ export const fetchSettingsSuccess = ({
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
},
});
@@ -71,15 +68,11 @@ 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 = {}) => ({
export const saveSettings = (formId, commitValues) => ({
type: SAVE_SETTINGS.BASE,
payload: { formId, commitValues, extendedProfile },
payload: { formId, commitValues },
});
export const saveSettingsBegin = () => ({

View File

@@ -10,11 +10,6 @@ export const YEAR_OF_BIRTH_OPTIONS = (() => {
return years.reverse();
})();
export const COPPA_COMPLIANCE_YEAR = (() => {
const currentYear = new Date().getFullYear();
return currentYear - 13;
})();
export const EDUCATION_LEVELS = [
'',
'p',
@@ -25,7 +20,7 @@ export const EDUCATION_LEVELS = [
'jhs',
'el',
'none',
'other',
'o',
];
export const GENDER_OPTIONS = [
@@ -34,21 +29,6 @@ export const GENDER_OPTIONS = [
'm',
'o',
];
export const WORK_EXPERIENCE_OPTIONS = [
'',
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'10+',
];
export const COUNTRY_WITH_STATES = 'US';

View File

@@ -8,13 +8,11 @@ import {
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 = {
@@ -33,15 +31,10 @@ export const defaultState = {
deleteAccount: deleteAccountReducer(),
siteLanguage: siteLanguageReducer(),
resetPassword: resetPasswordReducer(),
nameChange: nameChangeReducer(),
thirdPartyAuth: thirdPartyAuthReducer(),
nameChangeModal: false,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {},
};
const reducer = (state = defaultState, action = {}) => {
const reducer = (state = defaultState, action) => {
let dispatcherIsOpenForm;
switch (action.type) {
@@ -63,7 +56,6 @@ const reducer = (state = defaultState, action = {}) => {
loading: false,
loaded: true,
loadingError: null,
verifiedNameHistory: action.payload.verifiedNameHistory,
};
case FETCH_SETTINGS.FAILURE:
return {
@@ -97,7 +89,6 @@ const reducer = (state = defaultState, action = {}) => {
saveState: null,
errors: {},
drafts: {},
nameChangeModal: false,
};
}
return state;
@@ -115,15 +106,6 @@ const reducer = (state = defaultState, action = {}) => {
drafts: {},
};
case BEGIN_NAME_CHANGE:
return {
...state,
saveState: 'error',
nameChangeModal: {
formId: action.payload.formId,
},
};
case SAVE_SETTINGS.BEGIN:
return {
...state,
@@ -137,6 +119,7 @@ const reducer = (state = defaultState, action = {}) => {
values: { ...state.values, ...action.payload.values },
errors: {},
confirmationValues: {
...state.confirmationValues,
...action.payload.confirmationValues,
},
@@ -215,15 +198,6 @@ const reducer = (state = defaultState, action = {}) => {
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:

View File

@@ -25,13 +25,11 @@ import {
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,
@@ -40,12 +38,7 @@ import {
import { saga as thirdPartyAuthSaga } from '../third-party-auth';
// Services
import {
getSettings,
patchSettings,
getTimeZones,
getVerifiedNameHistory,
} from './service';
import { getSettings, patchSettings, getTimeZones } from './service';
export function* handleFetchSettings() {
try {
@@ -61,8 +54,6 @@ export function* handleFetchSettings() {
userId,
);
const verifiedNameHistory = yield call(getVerifiedNameHistory);
if (values.country) { yield put(fetchTimeZones(values.country)); }
yield put(fetchSettingsSuccess({
@@ -70,7 +61,6 @@ export function* handleFetchSettings() {
thirdPartyAuthProviders,
profileDataManager,
timeZones,
verifiedNameHistory,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));
@@ -83,8 +73,8 @@ export function* handleSaveSettings(action) {
yield put(saveSettingsBegin());
const { username, userId } = getAuthenticatedUser();
const { commitValues, formId, extendedProfile } = action.payload;
const commitData = Object.keys(extendedProfile).length > 0 ? extendedProfile : { [formId]: commitValues };
const { commitValues, formId } = action.payload;
const commitData = { [formId]: commitValues };
let savedValues = null;
if (formId === 'siteLanguage') {
const previousSiteLanguage = getLocale();
@@ -108,9 +98,6 @@ export function* handleSaveSettings(action) {
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));
@@ -139,9 +126,6 @@ export function* handleSaveMultipleSettings(action) {
}
} 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));
@@ -164,7 +148,6 @@ export default function* saga() {
deleteAccountSaga(),
siteLanguageSaga(),
resetPasswordSaga(),
nameChangeSaga(),
thirdPartyAuthSaga(),
]);
}

View File

@@ -1,6 +1,5 @@
import { createSelector, createStructuredSelector } from 'reselect';
import { siteLanguageListSelector, siteLanguageOptionsSelector } from '../site-language';
import { compareVerifiedNamesByCreatedDate } from '../../utils';
export const storeName = 'accountSettings';
@@ -8,74 +7,9 @@ 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,
};
},
accountSettings => accountSettings.values,
);
const draftsSelector = createSelector(
@@ -106,16 +40,16 @@ const isEditingSelector = createSelector(
(name, accountSettings) => accountSettings.openFormId === name,
);
const confirmationValuesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.confirmationValues,
);
const errorSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.errors,
);
const nameChangeModalSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.nameChangeModal,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
@@ -135,18 +69,7 @@ export const profileDataManagerSelector = createSelector(
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;
},
accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []),
);
/**
@@ -156,31 +79,13 @@ function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed;
}
export const formValuesSelector = createSelector(
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) || '';
}
formValues[name] = chooseFormValue(drafts[name], value) || '';
});
return formValues;
},
@@ -188,7 +93,7 @@ export const formValuesSelector = createSelector(
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
.map(({ time_zone, description }) => ({ // eslint-disable-line camelcase
value: time_zone, label: description, // eslint-disable-line camelcase
value: time_zone, label: description,
}));
const timeZonesSelector = createSelector(
@@ -225,35 +130,21 @@ export const accountSettingsPageSelector = createSelector(
siteLanguageOptionsSelector,
siteLanguageSelector,
formValuesSelector,
valuesSelector,
draftsSelector,
errorSelector,
profileDataManagerSelector,
staticFieldsSelector,
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
nameChangeModalSelector,
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
(
accountSettings,
siteLanguageOptions,
siteLanguage,
formValues,
committedValues,
drafts,
formErrors,
profileDataManager,
staticFields,
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
) => ({
siteLanguageOptions,
siteLanguage,
@@ -264,36 +155,37 @@ export const accountSettingsPageSelector = createSelector(
countryTimeZoneOptions,
isActive: activeAccount,
formValues,
committedValues,
drafts,
formErrors,
profileDataManager,
staticFields,
tpaProviders: accountSettings.thirdPartyAuth.providers,
nameChangeModal,
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
}),
);
export const certPreferenceSelector = createSelector(
valuesSelector,
export const coachingConsentPageSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
mostRecentApprovedVerifiedNameValueSelector,
activeAccountSelector,
profileDataManagerSelector,
saveStateSelector,
confirmationValuesSelector,
errorSelector,
(
committedValues,
accountSettings,
formValues,
mostRecentApprovedVerifiedNameValue,
activeAccount,
profileDataManager,
saveState,
confirmationValues,
errors,
) => ({
originalFullName: committedValues?.name || '',
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
isActive: activeAccount,
profileDataManager,
formValues,
saveState,
confirmationValues,
formErrors: errors,
}),
);
@@ -312,12 +204,3 @@ export const demographicsSectionSelector = createSelector(
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

@@ -7,7 +7,7 @@ import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
import { getDemographics, getDemographicsOptions, patchDemographics } from '../demographics/data/service';
import { DEMOGRAPHICS_FIELDS } from '../demographics/data/utils';
@@ -176,99 +176,52 @@ export async function shouldDisplayDemographicsQuestions() {
return false;
}
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));
}
/**
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, ThirdPartyAuth, and Demographics
* Currently encapsulates Account, Preferences, Coaching, ThirdPartyAuth, and Demographics
*/
export async function getSettings(username, userRoles, userId) {
const [
account,
preferences,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
shouldDisplayDemographicsQuestionsResponse,
demographics,
demographicsOptions,
] = await Promise.all([
const results = await Promise.all([
getAccount(username),
getPreferences(username),
getThirdPartyAuthProviders(),
getProfileDataManager(username, userRoles),
getTimeZones(),
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && shouldDisplayDemographicsQuestions(),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographics(userId),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographicsOptions(),
]);
return {
...account,
...preferences,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
shouldDisplayDemographicsSection: shouldDisplayDemographicsQuestionsResponse,
...demographics,
demographicsOptions,
...results[0],
...results[1],
thirdPartyAuthProviders: results[2],
profileDataManager: results[3],
timeZones: results[4],
coaching: results[5],
shouldDisplayDemographicsSection: results[6],
...results[7], // demographics
demographicsOptions: results[8],
};
}
/**
* A single function to PATCH everything considered a setting.
* Currently encapsulates Account, Preferences, ThirdPartyAuth
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
*/
export async function patchSettings(username, commitValues, userId) {
// Note: time_zone exists in the return value from user/v1/accounts
// but it is always null and won't update. It also exists in
// user/v1/preferences where it does update. This is the one we use.
const preferenceKeys = ['time_zone'];
const coachingKeys = ['coaching'];
const demographicsKeys = DEMOGRAPHICS_FIELDS;
const certificateKeys = ['useVerifiedNameForCerts'];
const isDemographicsKey = (value, key) => key.includes('demographics');
const accountCommitValues = omit(
commitValues,
preferenceKeys,
demographicsKeys,
certificateKeys,
);
const accountCommitValues = omit(commitValues, preferenceKeys, coachingKeys, demographicsKeys);
const preferenceCommitValues = pick(commitValues, preferenceKeys);
const coachingCommitValues = pick(commitValues, coachingKeys);
const demographicsCommitValues = pickBy(commitValues, isDemographicsKey);
const certCommitValues = pick(commitValues, certificateKeys);
const patchRequests = [];
if (!isEmpty(accountCommitValues)) {
@@ -277,12 +230,12 @@ export async function patchSettings(username, commitValues, userId) {
if (!isEmpty(preferenceCommitValues)) {
patchRequests.push(patchPreferences(username, preferenceCommitValues));
}
if (!isEmpty(coachingCommitValues)) {
patchRequests.push(patchCoachingPreferences(userId, coachingCommitValues));
}
if (!isEmpty(demographicsCommitValues)) {
patchRequests.push(patchDemographics(userId, demographicsCommitValues));
}
if (!isEmpty(certCommitValues)) {
patchRequests.push(postVerifiedNameConfig(username, certCommitValues));
}
const results = await Promise.all(patchRequests);
// Assigns in order of requests. Preference keys

View File

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

View File

@@ -3,7 +3,7 @@ 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';
import { Hyperlink } from '@edx/paragon';
// Messages
import { getConfig } from '@edx/frontend-platform';
@@ -25,12 +25,10 @@ const BeforeProceedingBanner = (props) => {
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 ? (
actionLink: (
<Hyperlink destination={supportArticleUrl}>
{intl.formatMessage(messages[instructionMessageId])}
</Hyperlink>
) : (
intl.formatMessage(messages[instructionMessageId])
),
siteName: getConfig().SITE_NAME,
}}

View File

@@ -1,48 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl, createIntl } from '@edx/frontend-platform/i18n';
ReactDOM.createPortal = node => node;
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

@@ -2,12 +2,12 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
AlertModal,
Button, Input, ValidationFormGroup, ActionRow,
} from '@openedx/paragon';
Button, Input, Modal, ValidationFormGroup,
} from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
import Alert from '../Alert';
@@ -74,58 +74,59 @@ export class ConfirmationModal extends Component {
: 'account.settings.delete.account.modal.text.2';
return (
<AlertModal
isOpen={open}
<Modal
open={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>
body={(
<div>
{this.renderError()}
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<h6>
{intl.formatMessage(
messages['account.settings.delete.account.modal.text.1'],
{ 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>
)}
>
<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>
buttons={[
<Button variant="danger" onClick={onSubmit}>
{intl.formatMessage(messages['account.settings.delete.account.modal.confirm.delete'])}
</Button>,
]}
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.confirm.cancel'])}
renderHeaderCloseButton={false}
onClose={onCancel}
/>
);
}
}

View File

@@ -3,7 +3,7 @@ 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';
import { Button, Hyperlink } from '@edx/paragon';
// Actions
import {
@@ -59,7 +59,6 @@ export class DeleteAccount extends React.Component {
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.
@@ -67,13 +66,9 @@ export class DeleteAccount extends React.Component {
? '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">
<h2 className="section-heading">
{intl.formatMessage(messages['account.settings.delete.account.header'])}
</h2>
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
@@ -93,10 +88,8 @@ export class DeleteAccount extends React.Component {
<PrintingInstructions />
</p>
<p className="text-danger h6">
{intl.formatMessage(
messages['account.settings.delete.account.text.warning'],
{ siteName: getConfig().SITE_NAME },
)}
{intl.formatMessage(messages['account.settings.delete.account.text.warning'],
{ siteName: getConfig().SITE_NAME })}
</p>
<p>
<Hyperlink destination="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings">
@@ -115,15 +108,15 @@ export class DeleteAccount extends React.Component {
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId={optInInstructionMessageId}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email-"
instructionMessageId="account.settings.delete.account.please.activate"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
/>
)}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportArticleUrl={supportArticleUrl}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/207206067"
/>
) : null}

View File

@@ -1,15 +1,10 @@
/* 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 <></>;
});
jest.mock('./ConfirmationModal');
jest.mock('./SuccessModal');
import { DeleteAccount } from './DeleteAccount'; // eslint-disable-line import/first
@@ -42,7 +37,6 @@ describe('DeleteAccount', () => {
</IntlProvider>
))
.toJSON();
expect(tree).toMatchSnapshot();
});

View File

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

View File

@@ -1,31 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ModalLayer, ModalCloseButton } from '@openedx/paragon';
import { Modal } from '@edx/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">
<Modal
open={status === 'deleted'}
title={intl.formatMessage(messages['account.settings.delete.account.modal.after.header'])}
body={(
<div>
<p className="h6">
{intl.formatMessage(messages['account.settings.delete.account.modal.after.text'])}
</p>
</div>
<p>
<ModalCloseButton className="float-right" variant="link">Close</ModalCloseButton>
</p>
</div>
</ModalLayer>
)}
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}
renderHeaderCloseButton={false}
onClose={onClose}
/>
);
};

View File

@@ -1,68 +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-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
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-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="http://test-support.edx"
onClick={[Function]}
target="_self"
>
unlink all social media accounts
</a>
.
</div>
</div>
`;

View File

@@ -1,68 +1,254 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `null`;
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `
Array [
<div
className="fade"
role="presentation"
/>,
<div
className="modal fade"
role="presentation"
>
<div
aria-labelledby="id1"
aria-modal={true}
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id1"
>
Are you sure?
</h2>
</div>
<div
className="modal-body"
>
<div>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={Object {}}
/>
</svg>
</div>
<div>
<h6>
You have selected "Delete My Account". Deletion of your account and personal data is permanent and cannot be undone. 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>
<span>
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.
</span>
</p>
</div>
</div>
<div
className="form-group"
>
<label
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<input
aria-describedby=""
className="form-control"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
>
Unable to delete account
</strong>
</div>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Cancel
</button>
<button
className="btn btn-danger"
disabled={false}
onClick={[MockFunction]}
type="button"
>
Yes, Delete
</button>
</div>
</div>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
</div>
</div>,
]
`;
exports[`ConfirmationModal should match empty password confirmation modal snapshot 1`] = `
Array [
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
className="modal-backdrop show"
role="presentation"
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
className="modal show d-block"
role="presentation"
>
<div
className="pgn__modal-content-container"
aria-labelledby="id3"
aria-modal={true}
className="modal-dialog"
role="dialog"
>
<div
className="pgn__modal-backdrop"
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
<div
aria-label="Are you sure?"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
role="dialog"
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={1}
/>
<div
data-focus-lock-disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
className="pgn__modal-header"
className="modal-content"
>
<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"
className="modal-header"
>
<div
className="p-3"
<h2
className="modal-title"
id="id3"
>
Are you sure?
</h2>
</div>
<div
className="modal-body"
>
<div>
<div
className="alert d-flex align-items-start alert-danger mt-n2"
>
@@ -126,13 +312,14 @@ Array [
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.
<span>
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.
</span>
</p>
</div>
</div>
<div
className="form-group"
data-testid="validation-form-group"
>
<label
className="d-block"
@@ -158,18 +345,13 @@ Array [
</div>
</div>
</div>
<div />
</div>
<div
className="pgn__modal-footer"
>
<div
className="pgn__action-row"
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[MockFunction]}
onClick={[Function]}
type="button"
>
Cancel
@@ -185,85 +367,99 @@ Array [
</div>
</div>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
</div>
</div>,
<div
data-focus-guard={true}
style={
Object {
"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`] = `
Array [
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
className="modal-backdrop show"
role="presentation"
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
className="modal show d-block"
role="presentation"
>
<div
className="pgn__modal-content-container"
aria-labelledby="id2"
aria-modal={true}
className="modal-dialog"
role="dialog"
>
<div
className="pgn__modal-backdrop"
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
<div
aria-label="Are you sure?"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__alert-modal"
role="dialog"
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={1}
/>
<div
data-focus-lock-disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
className="pgn__modal-header"
className="modal-content"
>
<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"
className="modal-header"
>
<div
className="p-3"
<h2
className="modal-title"
id="id2"
>
Are you sure?
</h2>
</div>
<div
className="modal-body"
>
<div>
<div
className="alert d-flex align-items-start alert-warning mt-n2"
>
@@ -294,13 +490,14 @@ Array [
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.
<span>
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.
</span>
</p>
</div>
</div>
<div
className="form-group"
data-testid="validation-form-group"
>
<label
className="d-block"
@@ -326,18 +523,13 @@ Array [
</div>
</div>
</div>
<div />
</div>
<div
className="pgn__modal-footer"
>
<div
className="pgn__action-row"
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[MockFunction]}
onClick={[Function]}
type="button"
>
Cancel
@@ -353,22 +545,22 @@ Array [
</div>
</div>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
</div>
</div>,
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>,
]
`;

View File

@@ -3,7 +3,7 @@
exports[`DeleteAccount should match default section snapshot 1`] = `
<div>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Delete My Account
</h2>
@@ -17,7 +17,9 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
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.
<span>
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.
</span>
</p>
<p
className="text-danger h6"
@@ -26,7 +28,6 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
@@ -50,7 +51,7 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<div>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Delete My Account
</h2>
@@ -64,7 +65,9 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
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.
<span>
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.
</span>
</p>
<p
className="text-danger h6"
@@ -73,7 +76,6 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
@@ -114,16 +116,17 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
</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-"
onClick={[Function]}
target="_self"
>
activate your account
</a>
.
<span>
Before proceeding, please
<a
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
onClick={[Function]}
target="_self"
>
activate your account
</a>
.
</span>
</div>
</div>
</div>
@@ -132,7 +135,7 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<div>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Delete My Account
</h2>
@@ -146,7 +149,9 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
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.
<span>
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.
</span>
</p>
<p
className="text-danger h6"
@@ -155,7 +160,6 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
@@ -196,16 +200,17 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
</svg>
</div>
<div>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/207206067"
onClick={[Function]}
target="_self"
>
unlink all social media accounts
</a>
.
<span>
Before proceeding, please
<a
href="https://support.edx.org/hc/en-us/articles/207206067"
onClick={[Function]}
target="_self"
>
unlink all social media accounts
</a>
.
</span>
</div>
</div>
</div>

View File

@@ -1,90 +1,563 @@
// 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 1`] = `
Array [
<div
className="fade"
role="presentation"
/>,
<div
className="modal fade"
role="presentation"
>
<div
aria-labelledby="id1"
aria-modal={true}
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id1"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
</div>
</div>,
]
`;
exports[`SuccessModal should match default closed success modal snapshot 2`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 2`] = `
Array [
<div
className="fade"
role="presentation"
/>,
<div
className="modal fade"
role="presentation"
>
<div
aria-labelledby="id2"
aria-modal={true}
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id2"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
</div>
</div>,
]
`;
exports[`SuccessModal should match default closed success modal snapshot 3`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 3`] = `
Array [
<div
className="fade"
role="presentation"
/>,
<div
className="modal fade"
role="presentation"
>
<div
aria-labelledby="id3"
aria-modal={true}
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id3"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
</div>
</div>,
]
`;
exports[`SuccessModal should match default closed success modal snapshot 4`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 4`] = `
Array [
<div
className="fade"
role="presentation"
/>,
<div
className="modal fade"
role="presentation"
>
<div
aria-labelledby="id4"
aria-modal={true}
className=""
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
<div
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id4"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>
</div>
</div>,
]
`;
exports[`SuccessModal should match open success modal snapshot 1`] = `
Array [
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
className="modal-backdrop show"
role="presentation"
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
className="modal show d-block"
role="presentation"
>
<div
className="pgn__modal-content-container"
aria-labelledby="id5"
aria-modal={true}
className="modal-dialog"
role="dialog"
>
<div
className="pgn__modal-backdrop"
data-testid="modal-backdrop"
onClick={[MockFunction]}
onKeyDown={[MockFunction]}
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
<div
className="mw-sm p-5 bg-white mx-auto my-3"
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={1}
/>
<div
data-focus-lock-disabled={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<h3>
We're sorry to see you go! Your account will be deleted shortly.
</h3>
<div
className="p-3"
className="modal-content"
>
<p
className="h6"
<div
className="modal-header"
>
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>
<h2
className="modal-title"
id="id5"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Close
</button>
</div>
</div>
<p>
<button
className="pgn__modal-close-button float-right btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Close
</button>
</p>
</div>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
</div>
</div>,
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>,
]
`;

View File

@@ -1,4 +1,3 @@
// 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';

View File

@@ -51,11 +51,6 @@ const messages = defineMessages({
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',

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Form } from '@openedx/paragon';
import { CheckBox } from '@edx/paragon';
import { DECLINED } from '../data/constants';
const Checkboxes = (props) => {
@@ -14,7 +14,7 @@ const Checkboxes = (props) => {
const [selected, setSelected] = useState(values);
useEffect(() => {
onChange(id, selected);
}, [id, onChange, selected]);
}, [selected]);
const handleToggle = (value, option) => {
// If the user checked 'declined', uncheck all other options
@@ -40,17 +40,16 @@ const Checkboxes = (props) => {
const isChecked = selected.includes(option.value);
return (
<div key={option.value} className="checkboxOption">
<Form.Checkbox
<CheckBox
type="checkbox"
id={option.value}
name={option.value}
value={option.value}
checked={isChecked}
autoFocus={isFirst}
onChange={(event) => handleToggle(event.target.checked, option.value)}
>
{option.label}
</Form.Checkbox>
label={option.label}
onChange={(value) => handleToggle(value, option.value)}
/>
</div>
);
});

View File

@@ -5,7 +5,7 @@ import {
intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink, Form } from '@openedx/paragon';
import { Hyperlink, Input } from '@edx/paragon';
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
@@ -13,7 +13,7 @@ import get from 'lodash.get';
import isEmpty from 'lodash.isempty';
import memoize from 'memoize-one';
import { demographicsSectionSelector } from '../data/selectors';
import EditableSelectField from '../EditableSelectField';
import EditableField from '../EditableField';
import Checkboxes from './Checkboxes';
import Alert from '../Alert';
import { saveMultipleSettings, updateDraft } from '../data/actions';
@@ -75,7 +75,7 @@ class DemographicsSection extends React.Component {
const matchingOption = demographicsEthnicityOptions.filter(option => option.value === e)[0];
return matchingOption && matchingOption.label;
}).join(', ');
};
}
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
@@ -162,8 +162,8 @@ class DemographicsSection extends React.Component {
const showWorkStatusDescribe = this.props.formValues.demographics_work_status === OTHER;
return (
<div className="account-section pt-3 mb-5" id="demographics-information" ref={this.props.forwardRef}>
<h2 className="section-heading h4 mb-3">
<div className="account-section" id="demographics-information" ref={this.props.forwardRef}>
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.demographics.information'])}
</h2>
<p>
@@ -188,7 +188,7 @@ class DemographicsSection extends React.Component {
*/}
{this.hasRetrievedDemographicsOptions() && (
<div id="demographics-fields">
<EditableSelectField
<EditableField
name="demographics_gender"
type="select"
value={this.props.formValues.demographics_gender}
@@ -199,7 +199,7 @@ class DemographicsSection extends React.Component {
{...editableFieldProps}
>
{showSelfDescribe && (
<Form.Control
<Input
name="demographics_gender_description"
id="field-demographics_gender_description"
type="text"
@@ -210,8 +210,8 @@ class DemographicsSection extends React.Component {
className="mt-1"
/>
)}
</EditableSelectField>
<EditableSelectField
</EditableField>
<EditableField
name="demographics_user_ethnicity"
type="select"
hidden
@@ -226,8 +226,8 @@ class DemographicsSection extends React.Component {
values={this.props.formValues.demographics_user_ethnicity}
{...editableFieldProps}
/>
</EditableSelectField>
<EditableSelectField
</EditableField>
<EditableField
name="demographics_income"
type="select"
value={this.props.formValues.demographics_income}
@@ -236,7 +236,7 @@ class DemographicsSection extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.income.empty'])}
{...editableFieldProps}
/>
<EditableSelectField
<EditableField
name="demographics_military_history"
type="select"
value={this.props.formValues.demographics_military_history}
@@ -245,7 +245,7 @@ class DemographicsSection extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.military_history.empty'])}
{...editableFieldProps}
/>
<EditableSelectField
<EditableField
name="demographics_learner_education_level"
type="select"
value={this.props.formValues.demographics_learner_education_level}
@@ -254,7 +254,7 @@ class DemographicsSection extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.learner_education_level.empty'])}
{...editableFieldProps}
/>
<EditableSelectField
<EditableField
name="demographics_parent_education_level"
type="select"
value={this.props.formValues.demographics_parent_education_level}
@@ -263,7 +263,7 @@ class DemographicsSection extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.parent_education_level.empty'])}
{...editableFieldProps}
/>
<EditableSelectField
<EditableField
name="demographics_work_status"
type="select"
value={this.props.formValues.demographics_work_status}
@@ -276,7 +276,7 @@ class DemographicsSection extends React.Component {
{...editableFieldProps}
>
{showWorkStatusDescribe && (
<Form.Control
<Input
name="demographics_work_status_description"
id="field-demographics_work_status_description"
type="text"
@@ -287,8 +287,8 @@ class DemographicsSection extends React.Component {
className="mt-1"
/>
)}
</EditableSelectField>
<EditableSelectField
</EditableField>
<EditableField
name="demographics_current_work_sector"
type="select"
value={this.props.formValues.demographics_current_work_sector}
@@ -297,7 +297,7 @@ class DemographicsSection extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.current_work_sector.empty'])}
{...editableFieldProps}
/>
<EditableSelectField
<EditableField
name="demographics_future_work_sector"
type="select"
value={this.props.formValues.demographics_future_work_sector}
@@ -317,7 +317,7 @@ DemographicsSection.propTypes = {
intl: intlShape.isRequired,
formValues: PropTypes.shape({
demographics_gender: PropTypes.string,
demographics_user_ethnicity: PropTypes.shape([]),
demographics_user_ethnicity: PropTypes.array,
demographics_income: PropTypes.string,
demographics_military_history: PropTypes.string,
demographics_learner_education_level: PropTypes.string,
@@ -327,11 +327,11 @@ DemographicsSection.propTypes = {
demographics_future_work_sector: PropTypes.string,
demographics_work_status_description: PropTypes.string,
demographics_gender_description: PropTypes.string,
demographicsOptions: PropTypes.shape({}),
demographicsOptions: PropTypes.object,
}).isRequired,
drafts: PropTypes.shape({
demographics_gender: PropTypes.string,
demographics_user_ethnicity: PropTypes.shape([]),
demographics_user_ethnicity: PropTypes.array,
demographics_income: PropTypes.string,
demographics_military_history: PropTypes.string,
demographics_learner_education_level: PropTypes.string,
@@ -341,7 +341,7 @@ DemographicsSection.propTypes = {
demographics_future_work_sector: PropTypes.string,
demographics_work_status_description: PropTypes.string,
demographics_gender_description: PropTypes.string,
demographicsOptions: PropTypes.shape({}),
demographicsOptions: PropTypes.object,
}).isRequired,
formErrors: PropTypes.shape({
demographicsError: PropTypes.string,

View File

@@ -1,4 +1,3 @@
/* eslint-disable no-import-assign */
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';

View File

@@ -2,58 +2,30 @@
exports[`DemographicsSection should render 1`] = `
<div
className="account-section pt-3 mb-5"
className="account-section"
id="demographics-information"
>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
data-testid="hyperlink-icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -113,7 +85,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -177,7 +148,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -248,7 +218,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -312,7 +281,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -376,7 +344,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -440,7 +407,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -504,7 +470,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -568,7 +533,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -632,7 +596,6 @@ exports[`DemographicsSection should render 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -649,58 +612,30 @@ exports[`DemographicsSection should render 1`] = `
exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
<div
className="account-section pt-3 mb-5"
className="account-section"
id="demographics-information"
>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
data-testid="hyperlink-icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -712,7 +647,9 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
>
<div />
<div>
An error occurred attempting to retrieve or save your account information. Please try again later.
<span>
An error occurred attempting to retrieve or save your account information. Please try again later.
</span>
</div>
</div>
</div>
@@ -772,7 +709,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -836,7 +772,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -907,7 +842,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -971,7 +905,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1035,7 +968,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1099,7 +1031,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1163,7 +1094,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1227,7 +1157,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1291,7 +1220,6 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1308,58 +1236,30 @@ exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
exports[`DemographicsSection should render an Alert when demographicsOptions props are empty 1`] = `
<div
className="account-section pt-3 mb-5"
className="account-section"
id="demographics-information"
>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
data-testid="hyperlink-icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -1371,7 +1271,9 @@ exports[`DemographicsSection should render an Alert when demographicsOptions pro
>
<div />
<div>
An error occurred attempting to retrieve or save your account information. Please try again later.
<span>
An error occurred attempting to retrieve or save your account information. Please try again later.
</span>
</div>
</div>
</div>
@@ -1380,58 +1282,30 @@ exports[`DemographicsSection should render an Alert when demographicsOptions pro
exports[`DemographicsSection should render ethnicity correctly when multiple options are selected 1`] = `
<div
className="account-section pt-3 mb-5"
className="account-section"
id="demographics-information"
>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
data-testid="hyperlink-icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -1491,7 +1365,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1555,7 +1428,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Hispanic, Latin, or Spanish origin, White
@@ -1619,7 +1491,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1683,7 +1554,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1747,7 +1617,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1811,7 +1680,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1875,7 +1743,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -1939,7 +1806,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2003,7 +1869,6 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2020,58 +1885,30 @@ exports[`DemographicsSection should render ethnicity correctly when multiple opt
exports[`DemographicsSection should render ethnicity text correctly 1`] = `
<div
className="account-section pt-3 mb-5"
className="account-section"
id="demographics-information"
>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
data-testid="hyperlink-icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -2131,7 +1968,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2195,7 +2031,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Asian
@@ -2259,7 +2094,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2323,7 +2157,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2387,7 +2220,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2451,7 +2283,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2515,7 +2346,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2579,7 +2409,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2643,7 +2472,6 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2660,58 +2488,30 @@ exports[`DemographicsSection should render ethnicity text correctly 1`] = `
exports[`DemographicsSection should set user input correctly when user provides answers to work_status question 1`] = `
<div
className="account-section pt-3 mb-5"
className="account-section"
id="demographics-information"
>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
data-testid="hyperlink-icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -2771,7 +2571,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2835,7 +2634,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -2906,7 +2704,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -2970,7 +2767,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3034,7 +2830,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3098,7 +2893,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3162,7 +2956,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Other: test
@@ -3226,7 +3019,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3290,7 +3082,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3307,58 +3098,30 @@ exports[`DemographicsSection should set user input correctly when user provides
exports[`DemographicsSection should set user input correctly when user provides gender self-description 1`] = `
<div
className="account-section pt-3 mb-5"
className="account-section"
id="demographics-information"
>
<h2
className="section-heading h4 mb-3"
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noreferrer"
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span>
<span
className="pgn__icon"
data-testid="hyperlink-icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
aria-hidden={false}
aria-label="Opens in a new window"
className="fa fa-external-link"
title="Opens in a new window"
/>
</span>
</a>
</p>
@@ -3418,7 +3181,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer to self describe: test
@@ -3482,7 +3244,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
<button
@@ -3553,7 +3314,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3617,7 +3377,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3681,7 +3440,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3745,7 +3503,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3809,7 +3566,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3873,7 +3629,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond
@@ -3937,7 +3692,6 @@ exports[`DemographicsSection should set user input correctly when user provides
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Prefer not to respond

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,4 +1,3 @@
// 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';

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 unexpired student, work, or government-issued 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;

View File

@@ -1,170 +0,0 @@
/* eslint-disable no-import-assign */
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
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 ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
import NameChange from '../NameChange'; // 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(() => ({ nameChangeSelector: () => ({}) })));
const IntlNameChange = injectIntl(NameChange);
const mockStore = configureStore();
describe('NameChange', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<Router>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
</Router>
);
beforeEach(() => {
store = mockStore();
props = {
targetFormId: 'test_form',
errors: {},
formValues: {
name: 'edx edx',
verified_name: 'edX Verified',
},
saveState: null,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
patch: async () => ({
data: { status: 200 },
catch: () => {},
}),
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, username: 'edx' }));
});
afterEach(() => jest.clearAllMocks());
it('renders populated input after clicking continue if verified_name in form data', async () => {
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
render(reduxWrapper(<IntlNameChange {...props} />));
expect(getInput()).toBeNull();
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
expect(getInput().value).toBe('edX Verified');
});
it('renders empty input after clicking continue if verified_name not in form data', async () => {
const getInput = () => screen.queryByPlaceholderText('Enter the name on your photo ID');
const formProps = {
...props,
formValues: {
name: 'edx edx',
},
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
expect(getInput().value).toBe('');
});
it('dispatches verifiedName on submit if targetForm is not "name"', async () => {
const dispatchData = {
payload: {
profileName: null,
username: 'edx',
verifiedName: 'Verified Name',
},
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
};
render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
});
it('dispatches both profileName and verifiedName on submit if the targetForm is "name"', async () => {
const dispatchData = {
payload: {
profileName: 'edx edx',
username: 'edx',
verifiedName: 'Verified Name',
},
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
};
const formProps = {
...props,
targetFormId: 'name',
};
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith(dispatchData);
});
it('does not dispatch action while pending', async () => {
props.saveState = 'pending';
render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
fireEvent.click(submitButton);
expect(mockDispatch).not.toHaveBeenCalled();
});
it('routes to IDV when name change request is successful', async () => {
props.saveState = 'complete';
render(reduxWrapper(<IntlNameChange {...props} />));
expect(window.location.pathname).toEqual('/id-verification');
});
});

View File

@@ -1,8 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
@@ -13,7 +12,7 @@ const ConfirmationAlert = (props) => {
const technicalSupportLink = (
<Hyperlink
destination={getConfig().PASSWORD_RESET_SUPPORT_LINK}
destination="https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-"
>
<FormattedMessage
id="account.settings.editable.field.password.reset.button.confirmation.support.link"

View File

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

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './ResetPassword';
export { default as reducer } from './data/reducers';
export { RESET_PASSWORD } from './data/actions';

View File

@@ -2,9 +2,8 @@ import { AsyncActionType } from '../data/utils';
export const FETCH_SITE_LANGUAGES = new AsyncActionType('SITE_LANGUAGE', 'FETCH_SITE_LANGUAGES');
export const fetchSiteLanguages = handleNavigation => ({
export const fetchSiteLanguages = () => ({
type: FETCH_SITE_LANGUAGES.BASE,
payload: { handleNavigation },
});
export const fetchSiteLanguagesBegin = () => ({

View File

@@ -19,11 +19,6 @@ const siteLanguageList = [
name: 'Español (Latinoamérica)',
released: true,
},
{
code: 'fa-ir',
name: 'فارسی',
released: true,
},
{
code: 'fr',
name: 'Français',
@@ -74,31 +69,6 @@ const siteLanguageList = [
name: '中文 (简体)',
released: true,
},
{
code: 'pt-pt',
name: 'Português',
released: true,
},
{
code: 'it-it',
name: 'Italian',
released: true,
},
{
code: 'de-de',
name: 'German',
released: true,
},
{
code: 'hi',
name: 'Hindi',
released: true,
},
{
code: 'fr-ca',
name: 'French (CA)',
released: true,
},
];
export default siteLanguageList;

View File

@@ -10,13 +10,13 @@ import {
import { getSiteLanguageList } from './service';
import { handleFailure } from '../data/utils';
function* handleFetchSiteLanguages(action) {
function* handleFetchSiteLanguages() {
try {
yield put(fetchSiteLanguagesBegin());
const siteLanguageList = yield call(getSiteLanguageList);
yield put(fetchSiteLanguagesSuccess(siteLanguageList));
} catch (e) {
yield call(handleFailure, e, action.payload.handleNavigation, fetchSiteLanguagesFailure);
yield call(handleFailure, e, fetchSiteLanguagesFailure);
}
}

View File

@@ -1,101 +0,0 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import { AppContext } from '@edx/frontend-platform/react';
import {
render, screen, fireEvent,
} from '@testing-library/react';
import configureStore from 'redux-mock-store';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import AccountSettingsPage from '../AccountSettingsPage';
import mockData from './mockData';
const mockDispatch = jest.fn();
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackingLogEvent: jest.fn(),
getCountryList: jest.fn(),
}));
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
jest.mock('@edx/frontend-platform/auth');
const IntlAccountSettingsPage = injectIntl(AccountSettingsPage);
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
describe('AccountSettingsPage', () => {
let props = {};
let store = {};
const appContext = { locale: 'en', authenticatedUser: { userId: 3, roles: [] } };
const reduxWrapper = children => (
<AppContext.Provider value={appContext}>
<Router>
<IntlProvider locale="en">
<Provider store={store}>
{children}
</Provider>
</IntlProvider>
</Router>
</AppContext.Provider>
);
beforeEach(() => {
store = mockStore(mockData);
props = {
loaded: true,
siteLanguage: {},
formValues: {
username: 'test_username',
accomplishments_shared: false,
name: 'test_name',
email: 'test_email@test.com',
id: 534,
extended_profile: [
{
field_name: 'work_experience',
field_value: '',
},
],
},
fetchSettings: jest.fn(),
};
});
afterEach(() => jest.clearAllMocks());
it('renders AccountSettingsPage correctly with editing enabled', async () => {
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<IntlAccountSettingsPage {...props} />));
const workExperienceText = getByText('Work Experience');
const workExperienceEditButton = workExperienceText.parentElement.querySelector('button');
expect(workExperienceEditButton).toBeInTheDocument();
store = mockStore({
...mockData,
accountSettings: {
...mockData.accountSettings,
openFormId: 'work_experience',
},
});
rerender(reduxWrapper(<IntlAccountSettingsPage {...props} />));
const submitButton = screen.getByText('Save');
expect(submitButton).toBeInTheDocument();
const workExperienceSelect = getByLabelText('Work Experience');
// Use fireEvent.change to simulate changing the selected value
fireEvent.change(workExperienceSelect, { target: { value: '4' } });
fireEvent.click(submitButton);
});
});

View File

@@ -1,169 +0,0 @@
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import configureStore from 'redux-mock-store';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import EditableSelectField from '../EditableSelectField';
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 IntlEditableSelectField = injectIntl(EditableSelectField);
const mockStore = configureStore();
describe('EditableSelectField', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<Router>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
</Router>
);
beforeEach(() => {
store = mockStore();
props = {
name: 'testField',
label: 'Main Label',
emptyLabel: 'Empty Main Label',
type: 'text',
value: 'Test Field',
userSuppliedValue: '',
options: [
{
label: 'Default Option',
value: 'defaultOption',
},
{
label: 'User Options',
group: [
{
label: 'Suboption 1',
value: 'suboption1',
},
],
},
{
label: 'Other Options',
group: [
{
label: 'Suboption 2',
value: 'suboption2',
},
{
label: 'Suboption 3',
value: 'suboption3',
},
],
},
],
saveState: 'default',
error: '',
confirmationMessageDefinition: {
id: 'confirmationMessageId',
defaultMessage: 'Default Confirmation Message',
description: 'Description of the confirmation message',
},
confirmationValue: 'Confirmation Value',
helpText: 'Helpful Text',
isEditing: false,
isEditable: true,
isGrayedOut: false,
};
});
afterEach(() => jest.clearAllMocks());
it('renders EditableSelectField correctly with editing disabled', () => {
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders EditableSelectField correctly with editing enabled', () => {
props = {
...props,
isEditing: true,
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...props} />)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders EditableSelectField with an error', () => {
const errorProps = {
...props,
error: 'This is an error message',
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...errorProps} />)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders selectOptions when option has a group', () => {
const propsWithGroup = {
...props,
options: [
{
label: 'User Options',
group: [
{
label: 'Suboption 1',
value: 'suboption1',
},
],
},
],
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroup} />)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders selectOptions when option does not have a group', () => {
const propsWithoutGroup = {
...props,
options: [
{
label: 'Default Option',
value: 'defaultOption',
},
],
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithoutGroup} />)).toJSON();
expect(tree).toMatchSnapshot();
});
it('renders selectOptions with multiple groups', () => {
const propsWithGroups = {
...props,
options: [
{
label: 'Mixed Options',
group: [
{
label: 'Suboption 1',
value: 'suboption1',
},
{
label: 'Suboption 2',
value: 'suboption2',
},
],
},
],
};
const tree = renderer.create(reduxWrapper(<IntlEditableSelectField {...propsWithGroups} />)).toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@@ -1,65 +1,45 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp, mergeConfig, setConfig } from '@edx/frontend-platform';
import { BrowserRouter as Router } from 'react-router-dom';
import { mergeConfig, setConfig } from '@edx/frontend-platform';
import JumpNav from '../JumpNav';
import configureStore from '../../data/configureStore';
const IntlJumpNav = injectIntl(JumpNav);
describe('JumpNav', () => {
mergeConfig({
ENABLE_DEMOGRAPHICS_COLLECTION: false,
ENABLE_ACCOUNT_DELETION: true,
});
let props = {};
let store;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
props = {
intl: {},
displayDemographicsLink: false,
};
store = configureStore({
notificationPreferences: {
showPreferences: false,
},
});
});
it('should not render Optional Information or delete account link', () => {
setConfig({
ENABLE_ACCOUNT_DELETION: false,
});
it('should not render Optional Information link', () => {
const tree = renderer.create((
<IntlProvider locale="en">
<AppProvider store={store}>
// Had to wrap the following in a router or I will receive an error stating:
// "Invariant failed: You should not use <NavLink> outside a <Router>"
<Router>
<IntlProvider locale="en">
<IntlJumpNav {...props} />
</AppProvider>
</IntlProvider>
</IntlProvider>
</Router>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should render Optional Information and delete account link', () => {
it('should render Optional Information link', () => {
setConfig({
ENABLE_DEMOGRAPHICS_COLLECTION: true,
ENABLE_ACCOUNT_DELETION: true,
});
props = {
@@ -68,11 +48,12 @@ describe('JumpNav', () => {
};
const tree = renderer.create((
<IntlProvider locale="en">
<AppProvider store={store}>
// Same as previous test
<Router>
<IntlProvider locale="en">
<IntlJumpNav {...props} />
</AppProvider>
</IntlProvider>
</IntlProvider>
</Router>
))
.toJSON();

View File

@@ -1,485 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditableSelectField renders EditableSelectField correctly with editing disabled 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="form-group"
>
<div
className="d-flex align-items-start"
>
<h6
aria-level="3"
>
Main Label
</h6>
<button
className="ml-3 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="svg-inline--fa fa-pencil-alt fa-w-16 mr-1"
data-icon="pencil-alt"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M497.9 142.1l-46.1 46.1c-4.7 4.7-12.3 4.7-17 0l-111-111c-4.7-4.7-4.7-12.3 0-17l46.1-46.1c18.7-18.7 49.1-18.7 67.9 0l60.1 60.1c18.8 18.7 18.8 49.1 0 67.9zM284.2 99.8L21.6 362.4.4 483.9c-2.9 16.4 11.4 30.6 27.8 27.8l121.5-21.3 262.6-262.6c4.7-4.7 4.7-12.3 0-17l-111-111c-4.8-4.7-12.4-4.7-17.1 0zM124.1 339.9c-5.5-5.5-5.5-14.3 0-19.8l154-154c5.5-5.5 14.3-5.5 19.8 0s5.5 14.3 0 19.8l-154 154c-5.5 5.5-14.3 5.5-19.8 0zM88 424h48v36.3l-64.5 11.3-31.1-31.1L51.7 376H88v48z"
fill="currentColor"
style={Object {}}
/>
</svg>
Edit
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Test Field
</p>
<p
className="small text-muted mt-n2"
>
Default Confirmation Message
</p>
</div>
</div>
</div>
`;
exports[`EditableSelectField renders EditableSelectField correctly with editing enabled 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<form
onSubmit={[Function]}
>
<div
className="pgn__form-group"
>
<label
className="pgn__form-label h6 d-block"
htmlFor="field-testField"
size="sm"
>
Main Label
</label>
<div
className="pgn__form-control-decorator-group"
>
<text
aria-describedby="field-testField-2"
className="has-value form-control is-invalid"
data-hj-suppress={true}
id="field-testField"
name="testField"
onBlur={[Function]}
onChange={[Function]}
type="text"
value="Test Field"
>
<option
value="defaultOption"
>
Default Option
</option>
<optgroup
label="User Options"
>
<option
value="suboption1"
>
Suboption 1
</option>
</optgroup>
<optgroup
label="Other Options"
>
<option
value="suboption2"
>
Suboption 2
</option>
<option
value="suboption3"
>
Suboption 3
</option>
</optgroup>
</text>
</div>
<div
className="pgn__form-text pgn__form-text-default"
>
<div>
Helpful Text
</div>
</div>
<div
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
id="field-testField-2"
>
<span
className="pgn__icon"
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"
fill="currentColor"
/>
</svg>
</span>
<div>
</div>
</div>
</div>
<p>
<button
aria-disabled={false}
aria-live="assertive"
className="pgn__stateful-btn pgn__stateful-btn-state-default mr-2 btn btn-primary"
disabled={false}
onClick={[Function]}
type="submit"
>
<span
className="d-flex align-items-center justify-content-center"
>
<span>
Save
</span>
</span>
</button>
<button
className="btn btn-outline-primary"
disabled={false}
onClick={[Function]}
type="button"
>
Cancel
</button>
</p>
</form>
</div>
</div>
`;
exports[`EditableSelectField renders EditableSelectField with an error 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="form-group"
>
<div
className="d-flex align-items-start"
>
<h6
aria-level="3"
>
Main Label
</h6>
<button
className="ml-3 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="svg-inline--fa fa-pencil-alt fa-w-16 mr-1"
data-icon="pencil-alt"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M497.9 142.1l-46.1 46.1c-4.7 4.7-12.3 4.7-17 0l-111-111c-4.7-4.7-4.7-12.3 0-17l46.1-46.1c18.7-18.7 49.1-18.7 67.9 0l60.1 60.1c18.8 18.7 18.8 49.1 0 67.9zM284.2 99.8L21.6 362.4.4 483.9c-2.9 16.4 11.4 30.6 27.8 27.8l121.5-21.3 262.6-262.6c4.7-4.7 4.7-12.3 0-17l-111-111c-4.8-4.7-12.4-4.7-17.1 0zM124.1 339.9c-5.5-5.5-5.5-14.3 0-19.8l154-154c5.5-5.5 14.3-5.5 19.8 0s5.5 14.3 0 19.8l-154 154c-5.5 5.5-14.3 5.5-19.8 0zM88 424h48v36.3l-64.5 11.3-31.1-31.1L51.7 376H88v48z"
fill="currentColor"
style={Object {}}
/>
</svg>
Edit
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Test Field
</p>
<p
className="small text-muted mt-n2"
>
Default Confirmation Message
</p>
</div>
</div>
</div>
`;
exports[`EditableSelectField renders selectOptions when option does not have a group 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="form-group"
>
<div
className="d-flex align-items-start"
>
<h6
aria-level="3"
>
Main Label
</h6>
<button
className="ml-3 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="svg-inline--fa fa-pencil-alt fa-w-16 mr-1"
data-icon="pencil-alt"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M497.9 142.1l-46.1 46.1c-4.7 4.7-12.3 4.7-17 0l-111-111c-4.7-4.7-4.7-12.3 0-17l46.1-46.1c18.7-18.7 49.1-18.7 67.9 0l60.1 60.1c18.8 18.7 18.8 49.1 0 67.9zM284.2 99.8L21.6 362.4.4 483.9c-2.9 16.4 11.4 30.6 27.8 27.8l121.5-21.3 262.6-262.6c4.7-4.7 4.7-12.3 0-17l-111-111c-4.8-4.7-12.4-4.7-17.1 0zM124.1 339.9c-5.5-5.5-5.5-14.3 0-19.8l154-154c5.5-5.5 14.3-5.5 19.8 0s5.5 14.3 0 19.8l-154 154c-5.5 5.5-14.3 5.5-19.8 0zM88 424h48v36.3l-64.5 11.3-31.1-31.1L51.7 376H88v48z"
fill="currentColor"
style={Object {}}
/>
</svg>
Edit
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Test Field
</p>
<p
className="small text-muted mt-n2"
>
Default Confirmation Message
</p>
</div>
</div>
</div>
`;
exports[`EditableSelectField renders selectOptions when option has a group 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="form-group"
>
<div
className="d-flex align-items-start"
>
<h6
aria-level="3"
>
Main Label
</h6>
<button
className="ml-3 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="svg-inline--fa fa-pencil-alt fa-w-16 mr-1"
data-icon="pencil-alt"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M497.9 142.1l-46.1 46.1c-4.7 4.7-12.3 4.7-17 0l-111-111c-4.7-4.7-4.7-12.3 0-17l46.1-46.1c18.7-18.7 49.1-18.7 67.9 0l60.1 60.1c18.8 18.7 18.8 49.1 0 67.9zM284.2 99.8L21.6 362.4.4 483.9c-2.9 16.4 11.4 30.6 27.8 27.8l121.5-21.3 262.6-262.6c4.7-4.7 4.7-12.3 0-17l-111-111c-4.8-4.7-12.4-4.7-17.1 0zM124.1 339.9c-5.5-5.5-5.5-14.3 0-19.8l154-154c5.5-5.5 14.3-5.5 19.8 0s5.5 14.3 0 19.8l-154 154c-5.5 5.5-14.3 5.5-19.8 0zM88 424h48v36.3l-64.5 11.3-31.1-31.1L51.7 376H88v48z"
fill="currentColor"
style={Object {}}
/>
</svg>
Edit
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Test Field
</p>
<p
className="small text-muted mt-n2"
>
Default Confirmation Message
</p>
</div>
</div>
</div>
`;
exports[`EditableSelectField renders selectOptions with multiple groups 1`] = `
<div
className="pgn-transition-replace-group position-relative"
style={
Object {
"height": null,
}
}
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="form-group"
>
<div
className="d-flex align-items-start"
>
<h6
aria-level="3"
>
Main Label
</h6>
<button
className="ml-3 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
<svg
aria-hidden="true"
className="svg-inline--fa fa-pencil-alt fa-w-16 mr-1"
data-icon="pencil-alt"
data-prefix="fas"
focusable="false"
role="img"
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M497.9 142.1l-46.1 46.1c-4.7 4.7-12.3 4.7-17 0l-111-111c-4.7-4.7-4.7-12.3 0-17l46.1-46.1c18.7-18.7 49.1-18.7 67.9 0l60.1 60.1c18.8 18.7 18.8 49.1 0 67.9zM284.2 99.8L21.6 362.4.4 483.9c-2.9 16.4 11.4 30.6 27.8 27.8l121.5-21.3 262.6-262.6c4.7-4.7 4.7-12.3 0-17l-111-111c-4.8-4.7-12.4-4.7-17.1 0zM124.1 339.9c-5.5-5.5-5.5-14.3 0-19.8l154-154c5.5-5.5 14.3-5.5 19.8 0s5.5 14.3 0 19.8l-154 154c-5.5 5.5-14.3 5.5-19.8 0zM88 424h48v36.3l-64.5 11.3-31.1-31.1L51.7 376H88v48z"
fill="currentColor"
style={Object {}}
/>
</svg>
Edit
</button>
</div>
<p
className={null}
data-hj-suppress={true}
>
Test Field
</p>
<p
className="small text-muted mt-n2"
>
Default Confirmation Message
</p>
</div>
</div>
</div>
`;

View File

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

View File

@@ -1,112 +0,0 @@
const mockData = {
accountSettings: {
loading: false,
loaded: true,
loadingError: null,
data: null,
values: {
username: 'test_username',
country: 'AD',
accomplishments_shared: false,
name: 'test_name',
email: 'test_email@test.com',
id: 533,
verified_name: null,
extended_profile: [
{
field_name: 'work_experience',
field_value: '',
},
],
gender: null,
'pref-lang': 'en',
shouldDisplayDemographicsSection: false,
demographicsOptions: false,
},
errors: {},
confirmationValues: {},
drafts: {},
saveState: null,
timeZones: [
{
time_zone: 'Africa/Abidjan',
description: 'Africa/Abidjan (GMT, UTC+0000)',
},
],
countryTimeZones: [
{
time_zone: 'Europe/Andorra',
description: 'Europe/Andorra (CET, UTC+0100)',
},
],
previousSiteLanguage: null,
deleteAccount: {
status: null,
errorType: null,
},
siteLanguage: {
loading: false,
loaded: true,
loadingError: null,
siteLanguageList: [
{
code: 'en',
name: 'English',
released: true,
},
],
},
resetPassword: {
status: null,
},
nameChange: {
saveState: null,
errors: {},
},
thirdPartyAuth: {
providers: [
{
id: 'oa2-google-oauth2',
name: 'Google',
connected: false,
accepts_logins: true,
connectUrl: 'http://localhost:18000/auth/login/google-oauth2/?auth_entry=account_settings&next=%2Faccount%2Fsettings',
disconnectUrl: 'http://localhost:18000/auth/disconnect/google-oauth2/?',
},
],
disconnectionStatuses: {},
errors: {},
},
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {
use_verified_name_for_certs: false,
results: [],
},
profileDataManager: null,
},
notificationPreferences: {
showPreferences: false,
courses: {
status: 'success',
courses: [],
pagination: {
count: 0,
currentPage: 1,
hasMore: false,
totalPages: 1,
},
},
preferences: {
status: 'idle',
updatePreferenceStatus: 'idle',
selectedCourse: null,
preferences: [],
apps: [],
nonEditable: {},
},
},
};
export default mockData;

View File

@@ -2,7 +2,7 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, StatefulButton } from '@openedx/paragon';
import { Hyperlink, StatefulButton } from '@edx/paragon';
import Alert from '../Alert';
import { disconnectAuth } from './data/actions';
@@ -16,7 +16,7 @@ class ThirdPartyAuth extends Component {
}
const disconnectUrl = e.currentTarget.getAttribute('data-disconnect-url');
this.props.disconnectAuth(disconnectUrl, providerId);
};
}
renderUnconnectedProvider(url, name) {
return (

View File

@@ -1,4 +1,3 @@
// eslint-disable-next-line no-restricted-exports
export { default } from './ThirdPartyAuth';
export { default as reducer } from './data/reducers';
export { default as saga } from './data/sagas';

BIN
src/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -1,4 +0,0 @@
export const IDLE_STATUS = 'idle';
export const LOADING_STATUS = 'loading';
export const SUCCESS_STATUS = 'success';
export const FAILURE_STATUS = 'failure';

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