Compare commits

..

2 Commits

Author SHA1 Message Date
Régis Behmo
5f1b6cccea Merge pull request #565 from regisb/regisb/upgrade-build-header
chore: upgrade frontend-build and frontend-component-header
2021-12-20 17:52:25 +01:00
Régis Behmo
4046c263b5 chore: upgrade frontend-build and frontend-component-header
Ran:

    npm install @edx/frontend-component-header@latest
    npm install @edx/frontend-build@latest
2021-12-20 17:28:48 +01:00
218 changed files with 30557 additions and 32594 deletions

20
.env
View File

@@ -1,10 +1,12 @@
ACCESS_TOKEN_COOKIE_NAME=''
ACCOUNT_PROFILE_URL=''
BASE_URL=''
COACHING_ENABLED=''
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=''
@@ -12,11 +14,9 @@ LOGIN_URL=''
LOGO_TRADEMARK_URL=''
LOGO_URL=''
LOGO_WHITE_URL=''
SHOW_PUSH_CHANNEL=''
SHOW_EMAIL_CHANNEL=''
LOGOUT_URL=''
MARKETING_SITE_BASE_URL=''
NODE_ENV='production'
NODE_ENV=''
ORDER_HISTORY_URL=''
PUBLISHER_BASE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
@@ -25,15 +25,3 @@ SITE_NAME=''
STUDIO_BASE_URL=''
SUPPORT_URL=''
USER_INFO_COOKIE_NAME=''
ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
APP_ID=
MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK=''
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -1,10 +1,12 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
ACCOUNT_PROFILE_URL='http://localhost:1995'
BASE_URL='localhost:1997'
COACHING_ENABLED=''
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'
@@ -24,17 +26,3 @@ SITE_NAME=localhost
STUDIO_BASE_URL=''
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
SHOW_PUSH_CHANNEL='true'
SHOW_EMAIL_CHANNEL='true'
APP_ID=
MFE_CONFIG_API_URL=
PASSWORD_RESET_SUPPORT_LINK='mailto:support@example.com'
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
# Fallback in local style files
PARAGON_THEME_URLS={}

View File

@@ -1,9 +1,12 @@
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1997'
COACHING_ENABLED=''
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'
@@ -22,15 +25,3 @@ SITE_NAME=localhost
STUDIO_BASE_URL=''
SUPPORT_URL='http://localhost:18000/support'
USER_INFO_COOKIE_NAME='edx-user-info'
ENABLE_COPPA_COMPLIANCE=''
ENABLE_ACCOUNT_DELETION=''
SHOW_PUSH_CHANNEL=''
SHOW_EMAIL_CHANNEL=''
ENABLE_DOB_UPDATE=''
MARKETING_EMAILS_OPT_IN=''
APP_ID=
MFE_CONFIG_API_URL=
LEARNER_FEEDBACK_URL=''
SUPPORT_URL_TO_UNLINK_SOCIAL_MEDIA_ACCOUNT='https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account'
COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED='[]'
PARAGON_THEME_URLS={}

View File

@@ -3,4 +3,3 @@ dist/
node_modules/
__mocks__/
__snapshots__/
src/i18n/messages/

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
---
name: ci
on:
push:
@@ -5,25 +6,27 @@ on:
- master
pull_request:
jobs:
tests:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 12
npm-test:
- i18n_extract
- is-es5
- lint
- test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version-file: '.nvmrc'
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 }}
- name: Coverage
if: matrix.npm-test == 'test'
uses: codecov/codecov-action@v4
- name: upload coverage
uses: codecov/codecov-action@v2
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
fail_ci_if_error: false

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: openedx/.github/.github/workflows/commitlint.yml@master
uses: edx/.github/.github/workflows/commitlint.yml@master

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

@@ -17,4 +17,3 @@ temp/babel-plugin-react-intl
/temp
/.vscode
/module.config.js
src/i18n/messages/

1
.nvmrc
View File

@@ -1 +0,0 @@
24

8
.tx/config Normal file
View File

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

39
Makefile Normal file → Executable file
View File

@@ -1,12 +1,16 @@
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_resource = frontend-app-account
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test
NPM_TESTS=build i18n_extract lint test is-es5
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
@@ -17,11 +21,6 @@ test.npm.%: validate-no-uncommitted-package-lock-changes
npm run $(*)
.PHONY: requirements
precommit:
npm run lint
npm audit
requirements: ## install ci requirements
npm ci
@@ -41,18 +40,20 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
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
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-account
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,48 +1,51 @@
####################
frontend-app-account
####################
|ci-badge| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
frontend-app-account
====================
********
Purpose
********
Please tag **@edx/community-engineering** on any PRs or issues. Thanks!
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
- Demographics collection
- IDV (Identity Verification)
***************
Getting Started
***************
Installation
------------
Prerequisites
=============
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.
`Tutor`_ is currently recommended as a development environment for your
new MFE. Please refer
to the `relevant tutor-mfe documentation`_ to get started using it.
1. Install Devstack using the `Getting Started <https://github.com/edx/devstack#getting-started>`_ instructions.
.. _Tutor: https://github.com/overhangio/tutor
2. Start up Devstack, if it's not already started.
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe?tab=readme-ov-file#mfe-development
3. Log in to Devstack (http://localhost:18000/login )
Plugins
=======
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
4. Within this project, install requirements and start the development server:
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
.. code-block::
npm install
npm start # The server will run on port 1997
5. Once the dev server is up, visit http://localhost:1997 to access the MFE
.. image:: ./docs/images/localhost_preview.png
Environment Variables/Setup Notes
=================================
---------------------------------
This MFE is configured via the ``frontend-platform`` configuration module. For more information on MFE configuration see the `Configuration documentation`_.
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>`__.
The account settings micro-frontend also supports the following additional variable:
@@ -52,22 +55,28 @@ Example: ``https://support.example.com``
The fully-qualified URL to the support page in the target environment.
``PASSWORD_RESET_SUPPORT_LINK``
edX-specific Environment Variables
**********************************
Examples:
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:
- ``https://support.edx.org/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message-``
``COACHING_ENABLED``
- ``mailto:support@example.com``
Example: ``true`` | ``''`` (empty strings are falsy)
The fully-qualified URL to the support page or email to request the support from in the target environment.
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_ACCOUNT_DELETION``
``ENABLE_DEMOGRAPHICS_COLLECTION``
Example: ``'false'`` | ``''`` (empty strings are true)
Example: ``true`` | ``''`` (empty strings are falsy)
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.
Enables support for a section of the account settings page where a user can enter demographics information. Integrates with a private demographics service and is only used by edx.org.
``DEMOGRAPHICS_BASE_URL``
Example: ``https://demographics.example.com``
Required only if ``ENABLE_DEMOGRAPHICS_COLLECTION`` is true. The fully-qualified URL to the private demographics service in the target environment.
Example build syntax with a single environment variable:
@@ -75,150 +84,29 @@ Example build syntax with a single environment variable:
NODE_ENV=development ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
For more information see the document: `Configuration documentation`_
.. _Configuration documentation: https://openedx.github.io/frontend-platform/module-Config.html
Cloning and Startup
===================
.. code-block::
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-app-account.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-app-account && npm ci``
4. Start the dev server:
``npm start``
Or for local development with custom configuration:
``npm run dev``
This runs the dev server with PUBLIC_PATH=/account/, MFE_CONFIG_API_URL pointing to localhost:8000, and hosts on apps.local.openedx.io.
Local module development
=========================
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
file (which is git-ignored) that defines where to find your local modules, for instance:
.. code-block:: js
module.exports = {
/*
Modules you want to use from local source code. Adding a module here means that when this app
runs its build, it'll resolve the source from peer directories of this app.
moduleName: the name you use to import code from the module.
dir: The relative path to the module's source code.
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
may want to use "src" if the module installs React as a peer/dev dependency.
*/
localModules: [
{ moduleName: '@openedx/paragon/scss', dir: '../paragon', dist: 'scss' },
{ moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' },
{ moduleName: '@openedx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
{ moduleName: '@openedx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
],
};
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
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>`__.
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/
People
======
The assigned maintainers for this component and other project details may be found in Backstage or from inspecting catalog-info.yaml.
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.
==============================
.. |ci-badge| image:: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml/badge.svg
:target: https://github.com/openedx/edx-developer-docs/actions/workflows/ci.yml
.. |ci-badge| image:: https://github.com/edx/edx-developer-docs/actions/workflows/ci.yml/badge.svg
:target: https://github.com/edx/edx-developer-docs/actions/workflows/ci.yml
:alt: Continuous Integration
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-account
:target: https://codecov.io/gh/openedx/frontend-app-account/
:target: https://codecov.io/gh/edx/frontend-app-account
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-account.svg
:target: @edx/frontend-app-account
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-account.svg

View File

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

View File

@@ -1,15 +0,0 @@
coverage:
status:
project:
default:
enabled: yes
target: auto
threshold: 0%
patch:
default:
enabled: yes
target: auto
threshold: 0%
ignore:
- "src/i18n"
- "src/index.jsx"

View File

@@ -1,4 +1,4 @@
const { createConfig } = require('@openedx/frontend-build');
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [

6
openedx.yaml Normal file
View File

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

43235
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,52 +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",
"dev": "PUBLIC_PATH=/account/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"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 .",
"lint:fix": "npm run lint -- --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
"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": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-brands-svg-icons": "^6.6.0",
"@fortawesome/free-regular-svg-icons": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.6.0",
"@fortawesome/react-fontawesome": "0.2.6",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@tensorflow-models/blazeface": "0.1.0",
"@tensorflow/tfjs-converter": "4.22.0",
"@tensorflow/tfjs-core": "4.22.0",
"bowser": "2.12.1",
"classnames": "2.5.1",
"core-js": "3.46.0",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-component-header": "^2.4.3",
"@edx/frontend-platform": "1.12.7",
"@edx/paragon": "16.1.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.1.15",
"@tensorflow-models/blazeface": "0.0.7",
"@tensorflow/tfjs-converter": "1.7.4",
"@tensorflow/tfjs-core": "1.7.4",
"bowser": "2.11.0",
"classnames": "2.3.1",
"core-js": "3.18.2",
"font-awesome": "4.7.0",
"form-urlencoded": "6.1.6",
"form-urlencoded": "4.0.1",
"formdata-polyfill": "4.0.10",
"jslib-html5-camera-photo": "3.3.4",
"lodash.camelcase": "4.3.0",
"history": "4.10.1",
"jslib-html5-camera-photo": "3.1.8",
"lodash.debounce": "4.0.8",
"lodash.findindex": "4.6.0",
"lodash.get": "4.4.2",
@@ -60,35 +58,36 @@
"lodash.omit": "4.5.0",
"lodash.pick": "4.4.0",
"lodash.pickby": "4.6.0",
"lodash.snakecase": "4.1.1",
"long": "5.3.2",
"memoize-one": "^6.0.0",
"prop-types": "15.8.1",
"qs": "6.14.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "^6.25.1",
"react-router-dom": "^6.25.1",
"react-router-hash-link": "2.4.3",
"memoize-one": "5.2.1",
"prop-types": "15.7.2",
"qs": "6.10.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.5",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-router-hash-link": "1.2.2",
"react-scrollspy": "3.4.3",
"react-transition-group": "4.4.5",
"redux": "4.2.1",
"react-transition-group": "4.4.2",
"redux": "4.1.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.3.0",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "^5.1.1",
"universal-cookie": "7.2.2"
"redux-saga": "1.1.3",
"redux-thunk": "2.3.0",
"regenerator-runtime": "0.13.9",
"reselect": "4.0.0",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@edx/browserslist-config": "1.5.0",
"@openedx/frontend-build": "^14.6.2",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "14.3.1",
"react-test-renderer": "^18.3.1",
"redux-mock-store": "1.5.5"
"@edx/frontend-build": "^9.0.5",
"@testing-library/jest-dom": "5.14.1",
"@testing-library/react": "12.1.2",
"codecov": "3.8.3",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"es-check": "6.0.0",
"react-test-renderer": "16.14.0",
"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="<%=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>
<% } %>
</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

@@ -22,11 +22,6 @@
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"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';
@@ -14,9 +14,9 @@ import {
getLanguageList,
} from '@edx/frontend-platform/i18n';
import {
Container, Hyperlink, Icon, Alert,
} from '@openedx/paragon';
import { CheckCircle, Error, WarningFilled } from '@openedx/paragon/icons';
Button, Hyperlink, Icon, Alert,
} from '@edx/paragon';
import { CheckCircle, Error, WarningFilled } from '@edx/paragon/icons';
import messages from './AccountSettingsPage.messages';
import {
@@ -31,35 +31,35 @@ import PageLoading from './PageLoading';
import JumpNav from './JumpNav';
import DeleteAccount from './delete-account';
import EditableField from './EditableField';
import EditableSelectField from './EditableSelectField';
import ResetPassword from './reset-password';
import NameChange from './name-change';
import ThirdPartyAuth from './third-party-auth';
import BetaLanguageBanner from './BetaLanguageBanner';
import EmailField from './EmailField';
import OneTimeDismissibleAlert from './OneTimeDismissibleAlert';
import DOBModal from './DOBForm';
import {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
GENDER_OPTIONS,
COUNTRY_WITH_STATES,
COPPA_COMPLIANCE_YEAR,
WORK_EXPERIENCE_OPTIONS,
getStatesList,
FIELD_LABELS,
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import { fetchNotificationPreferences } from '../notification-preferences/data/thunks';
import NotificationSettings from '../notification-preferences/NotificationSettings';
import { withLocation, withNavigate } from './hoc';
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
import CoachingToggle from './coaching/CoachingToggle';
import DemographicsSection from './demographics/DemographicsSection';
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,
};
@@ -67,8 +67,8 @@ class AccountSettingsPage extends React.Component {
this.navLinkRefs = {
'#basic-information': React.createRef(),
'#profile-information': React.createRef(),
'#demographics-information': React.createRef(),
'#social-media': React.createRef(),
'#notifications': React.createRef(),
'#site-preferences': React.createRef(),
'#linked-accounts': React.createRef(),
'#delete-account': React.createRef(),
@@ -76,9 +76,8 @@ class AccountSettingsPage extends React.Component {
}
componentDidMount() {
this.props.fetchNotificationPreferences();
this.props.fetchSettings();
this.props.fetchSiteLanguages(this.props.navigate);
this.props.fetchSiteLanguages();
sendTrackingLogEvent('edx.user.settings.viewed', {
page: 'account',
visibility: null,
@@ -124,15 +123,7 @@ class AccountSettingsPage extends React.Component {
countryOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(
this.removeDisabledCountries(
getCountryList(locale).map(({ code, name }) => ({
value: code,
label: name,
disabled: this.isDisabledCountry(code),
})),
),
),
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))),
stateOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
@@ -153,48 +144,30 @@ 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,
})),
}));
canDeleteAccount = () => {
const { committedValues } = this.props;
return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country);
};
sortDates = (a, b) => {
const aTimeSinceEpoch = new Date(a).getTime();
const bTimeSinceEpoch = new Date(b).getTime();
removeDisabledCountries = (countryList) => {
const { countriesCodesList, committedValues } = this.props;
const committedCountry = committedValues?.country;
return bTimeSinceEpoch - aTimeSinceEpoch;
}
if (!countriesCodesList.length) {
return countryList;
sortVerifiedNameRecords = verifiedNameHistory => {
if (Array.isArray(verifiedNameHistory)) {
return [...verifiedNameHistory].sort(this.sortDates);
}
return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value));
};
return [];
}
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
};
}
handleSubmit = (formId, values) => {
if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) {
return;
}
const { formValues } = this.props;
let extendedProfileObject = {};
if ('extended_profile' in formValues && formValues.extended_profile.some((field) => field.field_name === formId)) {
extendedProfileObject = {
extended_profile: formValues.extended_profile.map(field => (field.field_name === formId
? { ...field, field_value: values }
: field)),
};
}
this.props.saveSettings(formId, values, extendedProfileObject);
};
this.props.saveSettings(formId, values);
}
handleSubmitProfileName = (formId, values) => {
if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) {
@@ -222,13 +195,7 @@ class AccountSettingsPage extends React.Component {
} else {
this.props.saveSettings(formId, values);
}
};
isDisabledCountry = (country) => {
const { countriesCodesList } = this.props;
return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country);
};
}
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
@@ -245,12 +212,6 @@ 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">
@@ -298,28 +259,27 @@ class AccountSettingsPage extends React.Component {
);
}
renderFullNameHelpText = (status, proctoredExamId) => {
if (!this.props.verifiedNameHistory) {
renderFullNameHelpText = (status) => {
if (
!this.props.verifiedNameHistory
|| !this.props.verifiedNameEnabled
) {
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';
switch (status) {
case 'submitted':
if (this.props.committedValues.useVerifiedNameForCerts) {
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text.submitted']);
}
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text.submitted.certificate']);
default:
if (this.props.committedValues.useVerifiedNameForCerts) {
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text.non.certificate']);
}
return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text.certificate']);
}
if (!this.props.committedValues.useVerifiedNameForCerts) {
messageString += '.certificate';
}
return this.props.intl.formatMessage(messages[messageString]);
};
}
renderVerifiedNameSuccessMessage = (verifiedName, created) => {
const dateValue = new Date(created).valueOf();
@@ -334,7 +294,7 @@ class AccountSettingsPage extends React.Component {
body={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message'])}
/>
);
};
}
renderVerifiedNameFailureMessage = (verifiedName, created) => {
const dateValue = new Date(created).valueOf();
@@ -348,14 +308,24 @@ class AccountSettingsPage extends React.Component {
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">
{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
@@ -373,7 +343,7 @@ class AccountSettingsPage extends React.Component {
}
</p>
</Alert>
);
)
renderVerifiedNameMessage = verifiedNameRecord => {
const {
@@ -381,7 +351,6 @@ class AccountSettingsPage extends React.Component {
status,
profile_name: profileName,
verified_name: verifiedName,
proctored_exam_attempt_id: proctoredExamId,
} = verifiedNameRecord;
let willCertNameChange = false;
@@ -400,10 +369,6 @@ class AccountSettingsPage extends React.Component {
willCertNameChange = true;
}
if (proctoredExamId) {
return null;
}
switch (status) {
case 'approved':
return this.renderVerifiedNameSuccessMessage(verifiedName, created);
@@ -414,7 +379,7 @@ class AccountSettingsPage extends React.Component {
default:
return null;
}
};
}
renderVerifiedNameIcon = (status) => {
switch (status) {
@@ -425,32 +390,24 @@ class AccountSettingsPage extends React.Component {
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;
renderVerifiedNameHelpText = (status) => {
switch (status) {
case 'approved':
if (this.props.committedValues.useVerifiedNameForCerts) {
return this.props.intl.formatMessage(messages['account.settings.field.name.verified.help.text.certificate']);
}
return this.props.intl.formatMessage(messages['account.settings.field.name.verified.help.text.verified']);
case 'submitted':
if (this.props.committedValues.useVerifiedNameForCerts) {
return this.props.intl.formatMessage(messages['account.settings.field.name.verified.help.text.submitted.certificate']);
}
return this.props.intl.formatMessage(messages['account.settings.field.name.verified.help.text.submitted']);
default:
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()) {
@@ -485,6 +442,16 @@ class AccountSettingsPage extends React.Component {
);
}
renderDemographicsSection() {
// check the result of an LMS API call to determine if we should render the DemographicsSection component
if (this.props.formValues.shouldDisplayDemographicsSection) {
return (
<DemographicsSection forwardRef={this.navLinkRefs['#demographics-information']} />
);
}
return null;
}
renderContent() {
const editableFieldProps = {
onChange: this.handleEditableFieldChange,
@@ -499,15 +466,12 @@ 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 { country } = this.props.formValues;
const showState = country === COUNTRY_WITH_STATES && !this.isDisabledCountry(country);
const { verifiedName } = this.props;
const showState = this.props.formValues.country === COUNTRY_WITH_STATES;
const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience');
const { verifiedName, verifiedNameEnabled } = this.props;
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
this.props.timeZoneOptions,
@@ -516,39 +480,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']}>
<div className="account-section" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
{
this.props.mostRecentVerifiedName
verifiedNameEnabled && 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">
<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>
@@ -572,7 +512,8 @@ class AccountSettingsPage extends React.Component {
name="name"
type="text"
value={
verifiedName?.status === 'submitted'
verifiedNameEnabled
&& verifiedName?.status === 'submitted'
&& this.props.formValues.pending_name_change
? this.props.formValues.pending_name_change
: this.props.formValues.name
@@ -584,22 +525,22 @@ class AccountSettingsPage extends React.Component {
: this.renderEmptyStaticFieldMessage()
}
helpText={
verifiedName
? this.renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
verifiedNameEnabled && verifiedName
? this.renderFullNameHelpText(verifiedName.status)
: this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])
}
isEditable={
verifiedName
verifiedNameEnabled && verifiedName
? this.isEditable('verifiedName') && this.isEditable('name')
: this.isEditable('name')
}
isGrayedOut={
verifiedName && !this.isEditable('verifiedName')
verifiedNameEnabled && verifiedName && !this.isEditable('verifiedName')
}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitProfileName}
/>
{verifiedName
{verifiedNameEnabled && verifiedName
&& (
<EditableField
name="verified_name"
@@ -615,7 +556,7 @@ class AccountSettingsPage extends React.Component {
</div>
)
}
helpText={this.renderVerifiedNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)}
helpText={this.renderVerifiedNameHelpText(verifiedName.status)}
isEditable={this.isEditable('verifiedName')}
isGrayedOut={!this.isEditable('verifiedName')}
onChange={this.handleEditableFieldChange}
@@ -642,19 +583,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}
@@ -670,7 +608,7 @@ class AccountSettingsPage extends React.Component {
/>
{showState
&& (
<EditableSelectField
<EditableField
name="state"
type="select"
value={this.props.formValues.state}
@@ -687,23 +625,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}
@@ -712,19 +648,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}
@@ -733,11 +657,19 @@ class AccountSettingsPage extends React.Component {
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
{...editableFieldProps}
/>
<AdditionalProfileFieldsSlot />
{getConfig().COACHING_ENABLED
&& this.props.formValues.coaching.eligible_for_coaching
&& (
<CoachingToggle
name="coaching"
phone_number={this.props.formValues.phone_number}
coaching={this.props.formValues.coaching}
/>
)}
</div>
<div className="account-section pt-3 mb-6" id="social-media">
<h2 className="section-heading h4 mb-3">
{getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && this.renderDemographicsSection()}
<div className="account-section" id="social-media">
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
</h2>
<p>
@@ -772,17 +704,14 @@ class AccountSettingsPage extends React.Component {
{...editableFieldProps}
/>
</div>
<div className="border border-light-700" />
<div className="mt-6" id="notifications" ref={this.navLinkRefs['#notifications']}>
<NotificationSettings />
</div>
<div className="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<h2 className="section-heading h4 mb-3">
<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}
@@ -791,7 +720,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}
@@ -807,8 +736,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'],
@@ -818,15 +747,13 @@ 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}
canDeleteAccount={this.canDeleteAccount()}
/>
</div>
)}
<div className="account-section" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
<DeleteAccount
isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA}
/>
</div>
</>
);
}
@@ -855,7 +782,7 @@ class AccountSettingsPage extends React.Component {
} = this.props;
return (
<Container className="page__account-settings py-5" size="xl">
<div className="page__account-settings container-fluid py-5">
{this.renderDuplicateTpaProviderMessage()}
<h1 className="mb-4">
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
@@ -863,7 +790,9 @@ class AccountSettingsPage extends React.Component {
<div>
<div className="row">
<div className="col-md-3">
<JumpNav />
<JumpNav
displayDemographicsLink={this.props.formValues.shouldDisplayDemographicsSection}
/>
</div>
<div className="col-md-9">
{loading ? this.renderLoading() : null}
@@ -872,7 +801,7 @@ class AccountSettingsPage extends React.Component {
</div>
</div>
</div>
</Container>
</div>
);
}
}
@@ -891,15 +820,11 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string,
email: PropTypes.string,
secondary_email: PropTypes.string,
secondary_email_enabled: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
secondary_email_enabled: PropTypes.bool,
year_of_birth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
country: PropTypes.string,
level_of_education: PropTypes.string,
gender: PropTypes.string,
extended_profile: PropTypes.arrayOf(PropTypes.shape({
field_name: PropTypes.string,
field_value: PropTypes.string,
})),
language_proficiencies: PropTypes.string,
pending_name_change: PropTypes.string,
phone_number: PropTypes.string,
@@ -907,7 +832,13 @@ AccountSettingsPage.propTypes = {
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,
@@ -915,7 +846,6 @@ AccountSettingsPage.propTypes = {
name: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
verified_name: PropTypes.string,
country: PropTypes.string,
}),
drafts: PropTypes.shape({}),
formErrors: PropTypes.shape({
@@ -948,39 +878,25 @@ AccountSettingsPage.propTypes = {
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
beginNameChange: PropTypes.func.isRequired,
fetchNotificationPreferences: PropTypes.func.isRequired,
tpaProviders: PropTypes.arrayOf(PropTypes.shape({
connected: PropTypes.bool,
})),
nameChangeModal: PropTypes.oneOfType([
PropTypes.shape({
formId: PropTypes.string,
}),
PropTypes.bool,
]),
nameChangeModal: PropTypes.shape({
formId: PropTypes.string,
}),
verifiedNameEnabled: PropTypes.bool,
verifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
mostRecentVerifiedName: PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
verifiedNameHistory: PropTypes.arrayOf(
PropTypes.shape({
verified_name: PropTypes.string,
status: PropTypes.string,
proctored_exam_attempt_id: PropTypes.number,
}),
),
navigate: PropTypes.func.isRequired,
location: PropTypes.string.isRequired,
countriesCodesList: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
}),
),
};
@@ -992,7 +908,6 @@ AccountSettingsPage.defaultProps = {
committedValues: {
useVerifiedNameForCerts: false,
verified_name: null,
country: '',
},
drafts: {},
formErrors: {},
@@ -1005,19 +920,18 @@ AccountSettingsPage.defaultProps = {
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
nameChangeModal: {} || false,
nameChangeModal: {},
verifiedNameEnabled: false,
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: [],
countriesCodesList: [],
};
export default withLocation(withNavigate(connect(accountSettingsPageSelector, {
fetchNotificationPreferences,
export default connect(accountSettingsPageSelector, {
fetchSettings,
saveSettings,
saveMultipleSettings,
updateDraft,
fetchSiteLanguages,
beginNameChange,
})(injectIntl(AccountSettingsPage))));
})(injectIntl(AccountSettingsPage));

View File

@@ -46,6 +46,11 @@ const messages = defineMessages({
defaultMessage: 'Profile Information',
description: 'The profile information section heading.',
},
'account.settings.section.demographics.information': {
id: 'account.settings.section.demographics.information',
defaultMessage: 'Optional Information',
description: 'The optional information section heading.',
},
'account.settings.section.site.preferences': {
id: 'account.settings.section.site.preferences',
defaultMessage: 'Site Preferences',
@@ -86,13 +91,13 @@ 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',
'account.settings.field.full.name.help.text.non.certificate': {
id: 'account.settings.field.full.name.help.text.non.certificate',
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',
'account.settings.field.full.name.help.text.certificate': {
id: 'account.settings.field.full.name.help.text.certificate',
defaultMessage: 'This name is selected to appear on your certificates and public-facing records.',
description: 'Help text for the account settings name field.',
},
@@ -103,64 +108,39 @@ const messages = defineMessages({
},
'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.',
defaultMessage: 'This name has been verified by government 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.',
'account.settings.field.name.verified.help.text.certificate': {
id: 'account.settings.field.name.verified.help.text.certificate',
defaultMessage: 'This name has been verified by government ID and 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 government-issued ID.',
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.',
@@ -256,56 +236,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',
@@ -401,8 +331,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.',
},
@@ -555,26 +485,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,165 +0,0 @@
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Form, StatefulButton, ModalDialog, ActionRow, useToggle, Button,
} from '@openedx/paragon';
import { 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 intl = useIntl();
const {
saveState,
error,
onSubmit,
} = 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" data-testid="error-message">
{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} data-testid="open-modal-button">
{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"
data-testid="dob-modal"
>
<form onSubmit={handleSubmit} data-testid="dob-form">
<ModalDialog.Header>
<ModalDialog.Title data-testid="modal-title">
{intl.formatMessage(messages['account.settings.field.dob.form.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="overflow-hidden" style={{ padding: '1.5rem' }}>
<p data-testid="help-text">{intl.formatMessage(messages['account.settings.field.dob.form.help.text'])}</p>
<Form.Group>
<Form.Label data-testid="month-label">
{intl.formatMessage(messages['account.settings.field.dob.month'])}
</Form.Label>
<Form.Control
as="select"
name="month"
onChange={handleChange}
data-testid="month-select"
>
<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 data-testid="year-label">
{intl.formatMessage(messages['account.settings.field.dob.year'])}
</Form.Label>
<Form.Control
as="select"
name="year"
onChange={handleChange}
data-testid="year-select"
>
<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" data-testid="cancel-button">
Cancel
</ModalDialog.CloseButton>
<StatefulButton
type="submit"
state={!(monthValue && yearValue) ? 'unedited' : saveState}
labels={{
default: intl.formatMessage(messages['account.settings.editable.field.action.save']),
}}
disabledStates={['unedited']}
data-testid="submit-button"
/>
</ActionRow>
</ModalDialog.Footer>
</form>
</ModalDialog>
</>
);
};
DOBModal.propTypes = {
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
error: PropTypes.string,
onSubmit: PropTypes.func.isRequired,
};
DOBModal.defaultProps = {
saveState: undefined,
error: undefined,
};
export default connect(editableFieldSelector)(DOBModal);

View File

@@ -1,10 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
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';
@@ -18,7 +18,7 @@ import {
import { editableFieldSelector } from './data/selectors';
import CertificatePreference from './certificate-preference/CertificatePreference';
const EditableField = (props) => {
function EditableField(props) {
const {
name,
label,
@@ -26,6 +26,7 @@ const EditableField = (props) => {
type,
value,
userSuppliedValue,
options,
saveState,
error,
confirmationMessageDefinition,
@@ -38,10 +39,10 @@ const EditableField = (props) => {
isEditing,
isEditable,
isGrayedOut,
intl,
...others
} = props;
const id = `field-${name}`;
const intl = useIntl();
const handleSubmit = (e) => {
e.preventDefault();
@@ -73,6 +74,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}`;
}
@@ -84,13 +94,9 @@ const EditableField = (props) => {
if (!confirmationMessageDefinition || !confirmationValue) {
return null;
}
return (
<span data-testid="editable-field-confirmation">
{intl.formatMessage(confirmationMessageDefinition, {
value: confirmationValue,
})}
</span>
);
return intl.formatMessage(confirmationMessageDefinition, {
value: confirmationValue,
});
};
return (
@@ -99,26 +105,26 @@ const EditableField = (props) => {
cases={{
editing: (
<>
<form onSubmit={handleSubmit} data-testid="editable-field-form">
<Form.Group
controlId={id}
isInvalid={error != null}
<form onSubmit={handleSubmit}>
<ValidationFormGroup
for={id}
invalid={error != null}
invalidMessage={error}
helpText={helpText}
>
<Form.Label size="sm" 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}
type={type}
value={value}
onChange={handleChange}
data-testid="editable-field-textbox"
options={options}
{...others}
/>
{!!helpText && <Form.Text>{helpText}</Form.Text>}
{error != null && <Form.Control.Feedback hasIcon={false} data-testid="editable-field-error">{error}</Form.Control.Feedback>}
{others.children}
</Form.Group>
<>{others.children}</>
</ValidationFormGroup>
<p>
<StatefulButton
type="submit"
@@ -138,21 +144,16 @@ const EditableField = (props) => {
if (saveState === 'pending') { e.preventDefault(); }
}}
disabledStates={[]}
data-testid="editable-field-save"
/>
<Button
variant="outline-primary"
onClick={handleCancel}
data-testid="editable-field-cancel"
data-clicked="cancel"
>
{intl.formatMessage(messages['account.settings.editable.field.action.cancel'])}
</Button>
</p>
</form>
{['name', 'verified_name'].includes(name) && (
<CertificatePreference fieldName={name} data-testid="editable-field-certificate-preference" />
)}
{['name', 'verified_name'].includes(name) && <CertificatePreference fieldName={name} />}
</>
),
default: (
@@ -160,19 +161,19 @@ const EditableField = (props) => {
<div className="d-flex align-items-start">
<h6 aria-level="3">{label}</h6>
{isEditable ? (
<Button variant="link" onClick={handleEdit} className="ml-3" data-testid="editable-field-edit" data-clicked="edit">
<Button variant="link" onClick={handleEdit} className="ml-3">
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />{intl.formatMessage(messages['account.settings.editable.field.action.edit'])}
</Button>
) : null}
</div>
<p data-hj-suppress className={classNames('text-truncate', { 'grayed-out': isGrayedOut })}>{renderValue(value)}</p>
<p data-hj-suppress className={isGrayedOut ? 'grayed-out' : null}>{renderValue(value)}</p>
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
</div>
),
}}
/>
);
};
}
EditableField.propTypes = {
name: PropTypes.string.isRequired,
@@ -181,6 +182,10 @@ EditableField.propTypes = {
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({
@@ -197,10 +202,12 @@ EditableField.propTypes = {
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
isGrayedOut: PropTypes.bool,
intl: intlShape.isRequired,
};
EditableField.defaultProps = {
value: undefined,
options: undefined,
saveState: undefined,
label: undefined,
emptyLabel: undefined,
@@ -217,4 +224,4 @@ EditableField.defaultProps = {
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(EditableField);
})(injectIntl(EditableField));

View File

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

View File

@@ -1,9 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Button, StatefulButton, Form, Tooltip, OverlayTrigger,
} 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';
@@ -17,7 +18,7 @@ import {
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
const EmailField = (props) => {
function EmailField(props) {
const {
name,
label,
@@ -34,9 +35,9 @@ const EmailField = (props) => {
onChange,
isEditing,
isEditable,
intl,
} = props;
const id = `field-${name}`;
const intl = useIntl();
const handleSubmit = (e) => {
e.preventDefault();
@@ -105,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}
@@ -118,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"
@@ -161,23 +162,14 @@ const EmailField = (props) => {
</Button>
) : null}
</div>
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id={`tooltip-${name}`} variant="light" className="d-sm-none">
{renderValue()}
</Tooltip>
)}
>
<p data-hj-suppress className="text-truncate">{renderValue()}</p>
</OverlayTrigger>
<p data-hj-suppress>{renderValue()}</p>
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
</div>
),
}}
/>
);
};
}
EmailField.propTypes = {
name: PropTypes.string.isRequired,
@@ -199,6 +191,7 @@ EmailField.propTypes = {
onChange: PropTypes.func.isRequired,
isEditing: PropTypes.bool,
isEditable: PropTypes.bool,
intl: intlShape.isRequired,
};
EmailField.defaultProps = {
@@ -217,4 +210,4 @@ EmailField.defaultProps = {
export default connect(editableFieldSelector, {
onEdit: openForm,
onCancel: closeForm,
})(EmailField);
})(injectIntl(EmailField));

View File

@@ -1,30 +1,27 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { breakpoints, useWindowSize } from '@openedx/paragon';
import classNames from 'classnames';
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import messages from './AccountSettingsPage.messages';
const JumpNav = () => {
const intl = useIntl();
const stickToTop = useWindowSize().width > breakpoints.small.minWidth;
function JumpNav({ intl, displayDemographicsLink }) {
return (
<div className={classNames('jump-nav', { 'jump-nav-sm position-sticky pt-3': stickToTop })}>
<div className="jump-nav">
<Scrollspy
items={[
'basic-information',
'profile-information',
'demographics-information',
'social-media',
'notifications',
'site-preferences',
'linked-accounts',
'delete-account',
]}
className="list-unstyled"
currentClassName="font-weight-bold"
offset={-64}
>
<li>
<NavHashLink to="#basic-information">
@@ -36,16 +33,19 @@ const JumpNav = () => {
{intl.formatMessage(messages['account.settings.section.profile.information'])}
</NavHashLink>
</li>
{getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && displayDemographicsLink
&& (
<li>
<NavHashLink to="#demographics-information">
{intl.formatMessage(messages['account.settings.section.demographics.information'])}
</NavHashLink>
</li>
)}
<li>
<NavHashLink to="#social-media">
{intl.formatMessage(messages['account.settings.section.social.media'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#notifications">
{intl.formatMessage(messages['notification.preferences.notifications.label'])}
</NavHashLink>
</li>
<li>
<NavHashLink to="#site-preferences">
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
@@ -56,17 +56,19 @@ 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>
</div>
);
}
JumpNav.propTypes = {
intl: intlShape.isRequired,
displayDemographicsLink: PropTypes.bool.isRequired,
};
export default JumpNav;
export default injectIntl(JumpNav);

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,9 +1,9 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { Alert } from '@edx/paragon';
const OneTimeDismissibleAlert = (props) => {
export default function OneTimeDismissibleAlert(props) {
const [dismissed, setDismissed] = useState(localStorage.getItem(props.id) !== 'true');
const onClose = () => {
@@ -25,7 +25,7 @@ const OneTimeDismissibleAlert = (props) => {
</p>
</Alert>
);
};
}
OneTimeDismissibleAlert.propTypes = {
id: PropTypes.string.isRequired,
@@ -41,5 +41,3 @@ OneTimeDismissibleAlert.defaultProps = {
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,17 @@
}
}
.section-heading {
@extend .h4;
margin-bottom: map-get($spacers, 3);
}
.account-section {
// These properties together will shift the hashlink position
margin-bottom: map-get($spacers, 5);
padding-top: 1rem;
}
.custom-switch {
padding: 0;
max-width: 500px;
@@ -43,8 +55,3 @@
filter: alpha(opacity = 60); /* MSIE */
}
}
#tooltip-email .small {
display: block;
margin: 0 !important;
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { connect, useDispatch } from 'react-redux';
import PropTypes from 'prop-types';
@@ -7,8 +7,8 @@ import {
Form,
ModalDialog,
StatefulButton,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
} from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
closeForm,
@@ -21,20 +21,26 @@ import { certPreferenceSelector } from '../data/selectors';
import commonMessages from '../AccountSettingsPage.messages';
import messages from './messages';
const CertificatePreference = ({
function CertificatePreference({
intl,
fieldName,
originalFullName,
originalVerifiedName,
saveState,
useVerifiedNameForCerts,
}) => {
verifiedNameEnabled,
}) {
if (!verifiedNameEnabled || !originalVerifiedName) {
// If the user doesn't have an approved verified name, do not display this component
return null;
}
const dispatch = useDispatch();
const [checked, setChecked] = useState(false);
const [modalIsOpen, setModalIsOpen] = useState(false);
const formId = 'useVerifiedNameForCerts';
const intl = useIntl();
const handleCheckboxChange = () => {
function handleCheckboxChange() {
if (!checked) {
if (fieldName === 'verified_name') {
dispatch(updateDraft(formId, true));
@@ -44,22 +50,22 @@ const CertificatePreference = ({
} else {
setModalIsOpen(true);
}
};
}
const handleCancel = () => {
function handleCancel() {
setModalIsOpen(false);
dispatch(resetDrafts());
};
}
const handleModalChange = (e) => {
function handleModalChange(e) {
if (e.target.value === 'fullName') {
dispatch(updateDraft(formId, false));
} else {
dispatch(updateDraft(formId, true));
}
};
}
const handleSubmit = (e) => {
function handleSubmit(e) {
e.preventDefault();
if (saveState === 'pending') {
@@ -67,30 +73,24 @@ const CertificatePreference = ({
}
dispatch(saveSettings(formId, useVerifiedNameForCerts));
};
}
useEffect(() => {
if (originalVerifiedName) {
if (fieldName === 'verified_name') {
setChecked(useVerifiedNameForCerts);
} else {
setChecked(!useVerifiedNameForCerts);
}
if (fieldName === 'verified_name') {
setChecked(useVerifiedNameForCerts);
} else {
setChecked(!useVerifiedNameForCerts);
}
}, [originalVerifiedName, fieldName, useVerifiedNameForCerts]);
}, [useVerifiedNameForCerts]);
useEffect(() => {
if (originalVerifiedName) {
if (modalIsOpen && saveState === 'complete') {
setModalIsOpen(false);
dispatch(closeForm(fieldName));
}
if (modalIsOpen && saveState === 'complete') {
setModalIsOpen(false);
dispatch(closeForm(fieldName));
}
}, [dispatch, originalVerifiedName, fieldName, modalIsOpen, saveState]);
}, [modalIsOpen, saveState]);
// If the user doesn't have an approved verified name, do not display this component
return originalVerifiedName ? (
return (
<>
<Form.Checkbox className="mt-1 mb-4" checked={checked} onChange={handleCheckboxChange}>
{intl.formatMessage(messages['account.settings.field.name.checkbox.certificate.select'])}
@@ -151,15 +151,17 @@ const CertificatePreference = ({
</Form>
</ModalDialog>
</>
) : null;
};
);
}
CertificatePreference.propTypes = {
intl: intlShape.isRequired,
fieldName: PropTypes.string.isRequired,
originalFullName: PropTypes.string,
originalVerifiedName: PropTypes.string,
saveState: PropTypes.string,
useVerifiedNameForCerts: PropTypes.bool,
verifiedNameEnabled: PropTypes.bool,
};
CertificatePreference.defaultProps = {
@@ -167,6 +169,7 @@ CertificatePreference.defaultProps = {
originalVerifiedName: '',
saveState: null,
useVerifiedNameForCerts: false,
verifiedNameEnabled: false,
};
export default connect(certPreferenceSelector)(CertificatePreference);
export default connect(certPreferenceSelector)(injectIntl(CertificatePreference));

View File

@@ -1,76 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { postVerifiedNameConfig } from './service';
import { handleRequestError } from '../../data/utils';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/utils');
describe('postVerifiedNameConfig', () => {
const mockPost = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
getConfig.mockReturnValue({
LMS_BASE_URL: 'http://testserver',
});
getAuthenticatedHttpClient.mockReturnValue({
post: mockPost,
});
});
it('posts verified name config with useVerifiedNameForCerts = true', async () => {
const mockResponse = { data: { success: true } };
mockPost.mockResolvedValueOnce(mockResponse);
const result = await postVerifiedNameConfig('testuser', { useVerifiedNameForCerts: true });
expect(getConfig).toHaveBeenCalled();
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(mockPost).toHaveBeenCalledWith(
'http://testserver/api/edx_name_affirmation/v1/verified_name/config',
{
username: 'testuser',
use_verified_name_for_certs: true,
},
{ headers: { Accept: 'application/json' } },
);
expect(result).toEqual(mockResponse.data);
});
it('posts verified name config with useVerifiedNameForCerts = false', async () => {
const mockResponse = { data: { success: false } };
mockPost.mockResolvedValueOnce(mockResponse);
const result = await postVerifiedNameConfig('anotheruser', { useVerifiedNameForCerts: false });
expect(mockPost).toHaveBeenCalledWith(
'http://testserver/api/edx_name_affirmation/v1/verified_name/config',
{
username: 'anotheruser',
use_verified_name_for_certs: false,
},
{ headers: { Accept: 'application/json' } },
);
expect(result).toEqual(mockResponse.data);
});
it('calls handleRequestError and throws when request fails', async () => {
const mockError = new Error('Request failed');
mockPost.mockRejectedValueOnce(mockError);
handleRequestError.mockImplementation(() => {
throw mockError;
});
await expect(
postVerifiedNameConfig('erroruser', { useVerifiedNameForCerts: true }),
).rejects.toThrow('Request failed');
expect(handleRequestError).toHaveBeenCalledWith(mockError);
});
});

View File

@@ -1,23 +1,20 @@
/* 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 { Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import {
fireEvent,
render,
screen,
} from '@testing-library/react';
import { createMemoryHistory } from 'history';
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn(node => node), // Mock portal behavior
}));
// 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
@@ -30,6 +27,10 @@ jest.mock('react-redux', () => ({
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ certPreferenceSelector: () => ({}) })));
const history = createMemoryHistory();
const IntlCertificatePreference = injectIntl(CertificatePreference);
const mockStore = configureStore();
describe('NameChange', () => {
@@ -37,10 +38,10 @@ describe('NameChange', () => {
let store = {};
const formId = 'useVerifiedNameForCerts';
const updateDraft = 'UPDATE_DRAFT';
const labelText = messages['account.settings.field.name.checkbox.certificate.select'].defaultMessage;
const labelText = 'If checked, this name will appear on your certificates and public-facing records.';
const reduxWrapper = children => (
<Router>
<Router history={history}>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
@@ -55,6 +56,8 @@ describe('NameChange', () => {
originalVerifiedName: 'edX Verified',
saveState: null,
useVerifiedNameForCerts: false,
verifiedNameEnabled: true,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
@@ -74,7 +77,7 @@ describe('NameChange', () => {
originalVerifiedName: '',
};
const wrapper = render(reduxWrapper(<CertificatePreference {...props} />));
const wrapper = render(reduxWrapper(<IntlCertificatePreference {...props} />));
expect(wrapper).toMatchSnapshot();
});
@@ -85,7 +88,7 @@ describe('NameChange', () => {
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<CertificatePreference {...props} />));
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(false);
@@ -100,7 +103,7 @@ describe('NameChange', () => {
});
it('triggers modal when attempting to uncheck checkbox', () => {
render(reduxWrapper(<CertificatePreference {...props} />));
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);
@@ -112,7 +115,7 @@ describe('NameChange', () => {
});
it('updates draft when changing radio value', () => {
render(reduxWrapper(<CertificatePreference {...props} />));
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
@@ -130,7 +133,7 @@ describe('NameChange', () => {
});
it('clears draft on cancel', () => {
render(reduxWrapper(<CertificatePreference {...props} />));
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
@@ -143,7 +146,7 @@ describe('NameChange', () => {
});
it('submits', () => {
render(reduxWrapper(<CertificatePreference {...props} />));
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
fireEvent.click(checkbox);
@@ -151,7 +154,7 @@ describe('NameChange', () => {
const submitButton = screen.getByText('Choose name');
fireEvent.click(submitButton);
expect(mockDispatch).toHaveBeenCalledWith({
payload: { formId, commitValues: false, extendedProfile: {} },
payload: { formId, commitValues: false },
type: 'ACCOUNT_SETTINGS__SAVE_SETTINGS',
});
});
@@ -163,7 +166,7 @@ describe('NameChange', () => {
useVerifiedNameForCerts: true,
};
render(reduxWrapper(<CertificatePreference {...props} />));
render(reduxWrapper(<IntlCertificatePreference {...props} />));
const checkbox = screen.getByLabelText(labelText);
expect(checkbox.checked).toEqual(true);

View File

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

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

@@ -27,7 +27,6 @@ export const fetchSettingsSuccess = ({
profileDataManager,
timeZones,
verifiedNameHistory,
countriesCodesList,
}) => ({
type: FETCH_SETTINGS.SUCCESS,
payload: {
@@ -36,7 +35,6 @@ export const fetchSettingsSuccess = ({
profileDataManager,
timeZones,
verifiedNameHistory,
countriesCodesList,
},
});
@@ -79,9 +77,9 @@ export const beginNameChange = (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';
@@ -132,6 +112,6 @@ export function getStatesList(country) {
return country && COUNTRY_STATES_MAP[country.toUpperCase()];
}
export const FIELD_LABELS = {
COUNTRY: 'country',
};
export const DECLINED = 'declined';
export const SELF_DESCRIBE = 'self-describe';
export const OTHER = 'other';

View File

@@ -39,10 +39,10 @@ export const defaultState = {
verifiedName: null,
mostRecentVerifiedName: {},
verifiedNameHistory: {},
countriesCodesList: [],
verifiedNameEnabled: false,
};
const reducer = (state = defaultState, action = {}) => {
const reducer = (state = defaultState, action) => {
let dispatcherIsOpenForm;
switch (action.type) {
@@ -65,7 +65,6 @@ const reducer = (state = defaultState, action = {}) => {
loaded: true,
loadingError: null,
verifiedNameHistory: action.payload.verifiedNameHistory,
countriesCodesList: action.payload.countriesCodesList,
};
case FETCH_SETTINGS.FAILURE:
return {

View File

@@ -53,7 +53,7 @@ export function* handleFetchSettings() {
const { username, userId, roles: userRoles } = getAuthenticatedUser();
const {
thirdPartyAuthProviders, profileDataManager, timeZones, countries, ...values
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
} = yield call(
getSettings,
username,
@@ -71,7 +71,6 @@ export function* handleFetchSettings() {
profileDataManager,
timeZones,
verifiedNameHistory,
countriesCodesList: countries,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));
@@ -84,8 +83,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();

View File

@@ -12,6 +12,7 @@ const verifiedNameSettingsSelector = createSelector(
accountSettingsSelector,
accountSettings => ({
history: accountSettings.verifiedNameHistory.results,
verifiedNameEnabled: accountSettings?.verifiedNameHistory.verified_name_enabled,
useVerifiedNameForCerts: accountSettings?.verifiedNameHistory.use_verified_name_for_certs,
}),
);
@@ -88,11 +89,6 @@ const previousSiteLanguageSelector = createSelector(
accountSettings => accountSettings.previousSiteLanguage,
);
const countriesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.countriesCodesList,
);
const editableFieldErrorSelector = createSelector(
editableFieldNameSelector,
accountSettingsSelector,
@@ -111,6 +107,11 @@ const isEditingSelector = createSelector(
(name, accountSettings) => accountSettings.openFormId === name,
);
const confirmationValuesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.confirmationValues,
);
const errorSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.errors,
@@ -161,7 +162,7 @@ function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed;
}
export const formValuesSelector = createSelector(
const formValuesSelector = createSelector(
valuesSelector,
draftsSelector,
(values, drafts) => {
@@ -169,20 +170,6 @@ export const formValuesSelector = createSelector(
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) || '';
}
@@ -193,7 +180,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(
@@ -242,7 +229,7 @@ export const accountSettingsPageSelector = createSelector(
mostRecentApprovedVerifiedNameValueSelector,
mostRecentVerifiedNameSelector,
sortedVerifiedNameHistorySelector,
countriesSelector,
verifiedNameSettingsSelector,
(
accountSettings,
siteLanguageOptions,
@@ -260,7 +247,7 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
verifiedNameSettings,
) => ({
siteLanguageOptions,
siteLanguage,
@@ -281,17 +268,19 @@ export const accountSettingsPageSelector = createSelector(
verifiedName,
mostRecentVerifiedName,
verifiedNameHistory,
countriesCodesList,
verifiedNameEnabled: verifiedNameSettings?.verifiedNameEnabled,
}),
);
export const certPreferenceSelector = createSelector(
verifiedNameSettingsSelector,
valuesSelector,
formValuesSelector,
mostRecentApprovedVerifiedNameValueSelector,
saveStateSelector,
errorSelector,
(
verifiedNameSettings,
committedValues,
formValues,
mostRecentApprovedVerifiedNameValue,
@@ -302,6 +291,51 @@ export const certPreferenceSelector = createSelector(
originalVerifiedName: mostRecentApprovedVerifiedNameValue?.verified_name || '',
useVerifiedNameForCerts: formValues.useVerifiedNameForCerts || false,
saveState,
verifiedNameEnabled: verifiedNameSettings.verifiedNameEnabled || false,
formErrors: errors,
}),
);
export const coachingConsentPageSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
activeAccountSelector,
profileDataManagerSelector,
saveStateSelector,
confirmationValuesSelector,
errorSelector,
(
accountSettings,
formValues,
activeAccount,
profileDataManager,
saveState,
confirmationValues,
errors,
) => ({
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
isActive: activeAccount,
profileDataManager,
formValues,
saveState,
confirmationValues,
formErrors: errors,
}),
);
export const demographicsSectionSelector = createSelector(
formValuesSelector,
draftsSelector,
errorSelector,
(
formValues,
drafts,
errors,
) => ({
formValues,
drafts,
formErrors: errors,
}),
);

View File

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

View File

@@ -1,14 +1,16 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import pick from 'lodash.pick';
import pickBy from 'lodash.pickby';
import omit from 'lodash.omit';
import isEmpty from 'lodash.isempty';
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
import { postVerifiedNameConfig } from '../certificate-preference/data/service';
import { FIELD_LABELS } from './constants';
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
import { getDemographics, getDemographicsOptions, patchDemographics } from '../demographics/data/service';
import { DEMOGRAPHICS_FIELDS } from '../demographics/data/utils';
const SOCIAL_PLATFORMS = [
{ id: 'twitter', key: 'social_link_twitter' },
@@ -153,6 +155,41 @@ export async function getProfileDataManager(username, userRoles) {
return null;
}
/**
* A function to determine if the Demographics questions should be displayed to the user. For the
* MVP release of Demographics we are limiting the Demographics question visibility only to
* MicroBachelors learners.
*/
export async function shouldDisplayDemographicsQuestions() {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/demographics/v1/demographics/status/`;
let data = {};
try {
({ data } = await getAuthenticatedHttpClient().get(requestUrl));
if (data.display) {
return data.display;
}
} catch (error) {
// if there was an error then we just hide the section
return false;
}
return false;
}
export async function getVerifiedNameEnabled() {
let data;
const client = getAuthenticatedHttpClient();
try {
const requestUrl = `${getConfig().LMS_BASE_URL}/api/edx_name_affirmation/v1/verified_name_enabled`;
({ data } = await client.get(requestUrl));
} catch (error) {
return {};
}
return data;
}
export async function getVerifiedName() {
let data;
const client = getAuthenticatedHttpClient();
@@ -188,43 +225,31 @@ export async function postVerifiedName(data) {
.catch(error => handleRequestError(error));
}
function extractCountryList(data) {
return data?.fields
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
?.options?.map(({ value }) => (value)) || [];
}
export async function getCountryList() {
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return extractCountryList(data);
} catch (e) {
logError(e);
return [];
}
}
/**
* A single function to GET everything considered a setting. Currently encapsulates Account, Preferences, and
* ThirdPartyAuth.
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, Coaching, ThirdPartyAuth, and Demographics
*/
export async function getSettings(username, userRoles) {
export async function getSettings(username, userRoles, userId) {
const [
account,
preferences,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
countries,
coaching,
shouldDisplayDemographicsQuestionsResponse,
demographics,
demographicsOptions,
] = await Promise.all([
getAccount(username),
getPreferences(username),
getThirdPartyAuthProviders(),
getProfileDataManager(username, userRoles),
getTimeZones(),
getCountryList(),
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && shouldDisplayDemographicsQuestions(),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographics(userId),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographicsOptions(),
]);
return {
@@ -233,26 +258,36 @@ export async function getSettings(username, userRoles) {
thirdPartyAuthProviders,
profileDataManager,
timeZones,
countries,
coaching,
shouldDisplayDemographicsSection: shouldDisplayDemographicsQuestionsResponse,
...demographics,
demographicsOptions,
};
}
/**
* 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) {
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,
coachingKeys,
demographicsKeys,
certificateKeys,
);
const preferenceCommitValues = pick(commitValues, preferenceKeys);
const coachingCommitValues = pick(commitValues, coachingKeys);
const demographicsCommitValues = pickBy(commitValues, isDemographicsKey);
const certCommitValues = pick(commitValues, certificateKeys);
const patchRequests = [];
@@ -262,6 +297,12 @@ export async function patchSettings(username, commitValues) {
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));
}

View File

@@ -1,181 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { FIELD_LABELS } from './constants';
import {
getAccount,
patchAccount,
getPreferences,
patchPreferences,
getTimeZones,
getProfileDataManager,
getVerifiedName,
getVerifiedNameHistory,
postVerifiedName,
getCountryList,
patchSettings,
} from './service';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/auth');
jest.mock('@edx/frontend-platform/logging');
const mockHttpClient = {
get: jest.fn(),
patch: jest.fn(),
post: jest.fn(),
};
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
getConfig.mockReturnValue({ LMS_BASE_URL: 'http://lms.test' });
beforeEach(() => {
jest.clearAllMocks();
});
describe('account service', () => {
describe('getAccount', () => {
it('returns unpacked account data', async () => {
const apiResponse = {
username: 'testuser',
social_links: [{ platform: 'twitter', social_link: 'http://t' }],
language_proficiencies: [{ code: 'en' }],
};
mockHttpClient.get.mockResolvedValue({ data: apiResponse });
const result = await getAccount('testuser');
expect(mockHttpClient.get).toHaveBeenCalledWith('http://lms.test/api/user/v1/accounts/testuser');
expect(result.social_link_twitter).toEqual('http://t');
expect(result.language_proficiencies).toEqual('en');
});
});
describe('patchAccount', () => {
it('sends packed commit data and returns unpacked response', async () => {
const commit = { social_link_twitter: 'http://t' };
const apiResponse = {
username: 'testuser',
social_links: [{ platform: 'twitter', social_link: 'http://t' }],
language_proficiencies: [],
};
mockHttpClient.patch.mockResolvedValue({ data: apiResponse });
const result = await patchAccount('testuser', commit);
expect(mockHttpClient.patch).toHaveBeenCalledWith(
'http://lms.test/api/user/v1/accounts/testuser',
expect.objectContaining({ social_links: [{ platform: 'twitter', social_link: 'http://t' }] }),
expect.any(Object),
);
expect(result.social_link_twitter).toEqual('http://t');
});
});
describe('getPreferences', () => {
it('returns preferences data', async () => {
mockHttpClient.get.mockResolvedValue({ data: { theme: 'dark' } });
const result = await getPreferences('user');
expect(result.theme).toBe('dark');
});
});
describe('patchPreferences', () => {
it('patches preferences and returns commitValues', async () => {
mockHttpClient.patch.mockResolvedValue({});
const commit = { time_zone: 'UTC' };
const result = await patchPreferences('user', commit);
expect(mockHttpClient.patch).toHaveBeenCalled();
expect(result).toEqual(commit);
});
});
describe('getTimeZones', () => {
it('returns data from API', async () => {
mockHttpClient.get.mockResolvedValue({ data: ['UTC', 'PST'] });
const result = await getTimeZones('PK');
expect(mockHttpClient.get).toHaveBeenCalledWith(
'http://lms.test/user_api/v1/preferences/time_zones/',
{ params: { country_code: 'PK' } },
);
expect(result).toEqual(['UTC', 'PST']);
});
});
describe('getProfileDataManager', () => {
it('returns null if no enterprise manages profile', async () => {
mockHttpClient.get.mockResolvedValue({ data: { results: [] } });
const result = await getProfileDataManager('user', ['learner']);
expect(result).toBeNull();
});
it('returns enterprise name if sync is enabled', async () => {
mockHttpClient.get.mockResolvedValue({ data: { results: [{ enterprise_customer: { name: 'Acme', sync_learner_profile_data: true } }] } });
const result = await getProfileDataManager('user', ['enterprise_learner']);
expect(result).toBe('Acme');
});
});
describe('getVerifiedName', () => {
it('returns verified name data', async () => {
mockHttpClient.get.mockResolvedValue({ data: { verified: true } });
const result = await getVerifiedName();
expect(result.verified).toBe(true);
});
it('returns {} on error', async () => {
mockHttpClient.get.mockRejectedValue(new Error('fail'));
const result = await getVerifiedName();
expect(result).toEqual({});
});
});
describe('getVerifiedNameHistory', () => {
it('returns verified name history data', async () => {
mockHttpClient.get.mockResolvedValue({ data: [{ id: 1 }] });
const result = await getVerifiedNameHistory();
expect(result[0].id).toBe(1);
});
});
describe('postVerifiedName', () => {
it('posts verified name data', async () => {
mockHttpClient.post.mockResolvedValue({});
await postVerifiedName({ first_name: 'A' });
expect(mockHttpClient.post).toHaveBeenCalledWith(
'http://lms.test/api/edx_name_affirmation/v1/verified_name',
{ first_name: 'A' },
{ headers: { Accept: 'application/json' } },
);
});
});
describe('getCountryList', () => {
it('extracts country values from registration API', async () => {
const apiResponse = { fields: [{ name: FIELD_LABELS.COUNTRY, options: [{ value: 'PK' }] }] };
mockHttpClient.get.mockResolvedValue({ data: apiResponse });
const result = await getCountryList();
expect(result).toEqual(['PK']);
});
it('returns [] and logs error on failure', async () => {
mockHttpClient.get.mockRejectedValue(new Error('fail'));
const result = await getCountryList();
expect(result).toEqual([]);
expect(logError).toHaveBeenCalled();
});
});
describe('patchSettings', () => {
it('calls patchAccount and patchPreferences as needed', async () => {
mockHttpClient.patch.mockResolvedValue({
data: {
username: 'user',
social_links: [],
language_proficiencies: [],
},
});
const result = await patchSettings('user', { time_zone: 'UTC', social_link_twitter: 't' });
expect(result.username).toBe('user');
});
});
});

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

@@ -1,8 +1,9 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Hyperlink } from '@openedx/paragon';
import { Hyperlink } from '@edx/paragon';
// Messages
import { getConfig } from '@edx/frontend-platform';
@@ -12,8 +13,7 @@ import messages from './messages';
import Alert from '../Alert';
const BeforeProceedingBanner = (props) => {
const { instructionMessageId, supportArticleUrl } = props;
const intl = useIntl();
const { instructionMessageId, intl, supportArticleUrl } = props;
return (
<Alert
@@ -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,
}}
@@ -41,7 +39,8 @@ const BeforeProceedingBanner = (props) => {
BeforeProceedingBanner.propTypes = {
instructionMessageId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
supportArticleUrl: PropTypes.string.isRequired,
};
export default BeforeProceedingBanner;
export default injectIntl(BeforeProceedingBanner);

View File

@@ -1,45 +0,0 @@
import renderer from 'react-test-renderer';
import { IntlProvider } from '@edx/frontend-platform/i18n';
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn(node => node), // Mock portal behavior
}));
import BeforeProceedingBanner from './BeforeProceedingBanner'; // eslint-disable-line import/first
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',
supportArticleUrl: '',
};
const tree = renderer
.create((
<IntlProvider locale="en">
<BeforeProceedingBanner
{...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',
supportArticleUrl: 'http://test-support.edx',
};
const tree = renderer
.create((
<IntlProvider locale="en">
<BeforeProceedingBanner
{...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, Form, 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,63 +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}
isOverflowVisible
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>
<Form.Group
for={passwordFieldId}
isInvalid={errorType !== null}
>
<Form.Label className="d-block" htmlFor={passwordFieldId}>
{intl.formatMessage(messages['account.settings.delete.account.modal.enter.password'])}
</Form.Label>
<Form.Control
name="password"
id={passwordFieldId}
type="password"
value={password}
onChange={onChange}
/>
{errorType !== null && (
<Form.Control.Feedback type="invalid" feedback-for={passwordFieldId}>
{intl.formatMessage(invalidMessage)}
</Form.Control.Feedback>
)}
</Form.Group>
</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

@@ -1,12 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn(node => node), // Mock portal behavior
}));
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
import { ConfirmationModal } from './ConfirmationModal'; // eslint-disable-line import/first

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,83 +66,70 @@ 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>
{
this.props.canDeleteAccount ? (
<>
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
<p>
{intl.formatMessage(
messages['account.settings.delete.account.text.1'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
{intl.formatMessage(
messages[deleteAccountText2MessageKey],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
<PrintingInstructions />
</p>
<p className="text-danger h6">
{intl.formatMessage(
messages['account.settings.delete.account.text.warning'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
<Hyperlink destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics">
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
</Hyperlink>
</p>
<p>
<Button
variant="outline-danger"
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
disabled={!canDelete}
>
{intl.formatMessage(messages['account.settings.delete.account.button'])}
</Button>
</p>
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId={optInInstructionMessageId}
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
/>
)}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportArticleUrl={supportArticleUrl}
/>
) : null}
<p>{intl.formatMessage(messages['account.settings.delete.account.subheader'])}</p>
<p>
{intl.formatMessage(
messages['account.settings.delete.account.text.1'],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
{intl.formatMessage(
messages[deleteAccountText2MessageKey],
{ siteName: getConfig().SITE_NAME },
)}
</p>
<p>
<PrintingInstructions />
</p>
<p className="text-danger h6">
{intl.formatMessage(messages['account.settings.delete.account.text.warning'],
{ siteName: getConfig().SITE_NAME })}
</p>
<p>
<Hyperlink destination="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings">
{intl.formatMessage(messages['account.settings.delete.account.text.change.instead'])}
</Hyperlink>
</p>
<p>
<Button
variant="outline-danger"
onClick={canDelete ? this.props.deleteAccountConfirmation : null}
disabled={!canDelete}
>
{intl.formatMessage(messages['account.settings.delete.account.button'])}
</Button>
</p>
<ConnectedConfirmationModal
status={status}
errorType={errorType}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
onChange={this.handlePasswordChange}
password={this.state.password}
/>
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.activate"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
/>
)}
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
</>
) : (
<p>{intl.formatMessage(messages['account.settings.cannot.delete.account.text'])}</p>
)
}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/207206067"
/>
) : null}
<ConnectedConfirmationModal
status={status}
errorType={errorType}
onSubmit={this.handleSubmit}
onCancel={this.handleCancel}
onChange={this.handlePasswordChange}
password={this.state.password}
/>
<ConnectedSuccessModal status={status} onClose={this.handleFinalClose} />
</div>
);
}
@@ -159,7 +145,6 @@ DeleteAccount.propTypes = {
errorType: PropTypes.oneOf(['empty-password', 'server']),
hasLinkedTPA: PropTypes.bool,
isVerifiedAccount: PropTypes.bool,
canDeleteAccount: PropTypes.bool,
intl: intlShape.isRequired,
};
@@ -168,7 +153,6 @@ DeleteAccount.defaultProps = {
isVerifiedAccount: true,
status: null,
errorType: null,
canDeleteAccount: true,
};
// Assume we're part of the accountSettings state.

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,19 +1,19 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import React from 'react';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const PrintingInstructions = () => {
const intl = useIntl();
const PrintingInstructions = (props) => {
const actionLink = (
<Hyperlink
// TODO: What would a generic version of this link look like? Should
// CERTIFICATE_SHARING_HELP_URL really be a configuration variable? In the meantime,
// We've removed the link from the default message.
destination="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UVVOA2/certificates"
destination="https://support.edx.org/hc/en-us/sections/115004173027-Receive-and-Share-edX-Certificates"
>
{intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
{props.intl.formatMessage(messages['account.settings.delete.account.text.3.link'])}
</Hyperlink>
);
@@ -40,4 +40,8 @@ const PrintingInstructions = () => {
);
};
export default PrintingInstructions;
PrintingInstructions.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(PrintingInstructions);

View File

@@ -1,36 +1,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ModalLayer, ModalCloseButton } from '@openedx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Modal } from '@edx/paragon';
import messages from './messages';
export const SuccessModal = (props) => {
const intl = useIntl();
const { status, onClose } = 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">{intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}</ModalCloseButton>
</p>
</div>
</ModalLayer>
)}
closeText={intl.formatMessage(messages['account.settings.delete.account.modal.after.button'])}
renderHeaderCloseButton={false}
onClose={onClose}
/>
);
};
SuccessModal.propTypes = {
status: PropTypes.oneOf(['confirming', 'pending', 'deleted', 'failed']),
intl: intlShape.isRequired,
onClose: PropTypes.func.isRequired,
};
@@ -38,4 +35,4 @@ SuccessModal.defaultProps = {
status: null,
};
export default SuccessModal;
export default injectIntl(SuccessModal);

View File

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

View File

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

View File

@@ -1,87 +1,273 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `null`;
exports[`ConfirmationModal should match empty password confirmation modal snapshot 1`] = `
[
exports[`ConfirmationModal should match default closed confirmation modal snapshot 1`] = `
Array [
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
className="fade"
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 fade"
role="presentation"
>
<div
className="pgn__modal-content-container"
aria-labelledby="id1"
aria-modal={true}
className=""
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={-1}
/>
<div
aria-label="Are you sure?"
className="pgn__modal pgn__modal-md pgn__modal-default pgn__modal-visible-overflow 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="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[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="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
className="modal-backdrop show"
role="presentation"
/>,
<div
className="modal show d-block"
role="presentation"
>
<div
aria-labelledby="id3"
aria-modal={true}
className="modal-dialog"
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
<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={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<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"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-circle-exclamation mr-2"
data-icon="circle-exclamation"
className="svg-inline--fa fa-exclamation-circle fa-w-16 mr-2"
data-icon="exclamation-circle"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
style={Object {}}
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"
d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"
fill="currentColor"
style={{}}
style={Object {}}
/>
</svg>
</div>
@@ -102,19 +288,19 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
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={{}}
style={Object {}}
/>
</svg>
</div>
@@ -126,77 +312,46 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
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="pgn__form-group"
for="passwordFieldId"
className="form-group"
>
<label
className="pgn__form-label d-block"
htmlFor="form-field3"
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<div
className="pgn__form-control-decorator-group"
<input
aria-describedby="passwordFieldId-invalid-feedback"
className="form-control is-invalid"
id="passwordFieldId"
name="password"
onChange={[MockFunction]}
type="password"
value="fluffy bunnies"
/>
<strong
className="invalid-feedback"
id="passwordFieldId-invalid-feedback"
>
<input
aria-describedby="form-field3-5"
className="has-value form-control is-invalid"
id="form-field3"
name="password"
onBlur={[Function]}
onChange={[Function]}
type="password"
value="fluffy bunnies"
/>
</div>
<div
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
feedback-for="passwordFieldId"
id="form-field3-5"
>
<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>
A password is required
</div>
</div>
A password is required
</strong>
</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
@@ -212,104 +367,118 @@ exports[`ConfirmationModal should match empty password confirmation modal snapsh
</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={
{
"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={
{
"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__modal-visible-overflow 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"
>
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
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={{}}
style={Object {}}
/>
</svg>
</div>
@@ -321,48 +490,46 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
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="pgn__form-group"
for="passwordFieldId"
className="form-group"
>
<label
className="pgn__form-label d-block"
htmlFor="form-field1"
className="d-block"
htmlFor="passwordFieldId"
>
If you still wish to continue and delete your account, please enter your account password:
</label>
<div
className="pgn__form-control-decorator-group"
<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"
>
<input
className="has-value form-control"
id="form-field1"
name="password"
onBlur={[Function]}
onChange={[Function]}
type="password"
value="fluffy bunnies"
/>
</div>
Unable to delete account
</strong>
</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
@@ -378,22 +545,22 @@ exports[`ConfirmationModal should match open confirmation modal snapshot 1`] = `
</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={
{
"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,8 +28,9 @@ exports[`DeleteAccount should match default section snapshot 1`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
className="default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -49,7 +52,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>
@@ -63,7 +66,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"
@@ -72,8 +77,9 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
className="default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -95,32 +101,35 @@ exports[`DeleteAccount should match unverified account section snapshot 1`] = `
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
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={{}}
style={Object {}}
/>
</svg>
</div>
<div>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-confirm-my-email"
target="_self"
>
activate your account
</a>
.
<span>
Before proceeding, please
<a
className="default-link standalone-link"
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>
@@ -129,7 +138,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>
@@ -143,7 +152,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"
@@ -152,8 +163,9 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
</p>
<p>
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/topic/0TOQq0000001UdZOAU/account-basics"
className="default-link standalone-link"
href="https://support.edx.org/hc/en-us/sections/115004139268-Manage-Your-Account-Settings"
onClick={[Function]}
target="_self"
>
Want to change your email, name, or password instead?
@@ -175,32 +187,35 @@ exports[`DeleteAccount should match unverified account section snapshot 2`] = `
<div>
<svg
aria-hidden="true"
className="svg-inline--fa fa-triangle-exclamation mr-2"
data-icon="triangle-exclamation"
className="svg-inline--fa fa-exclamation-triangle fa-w-18 mr-2"
data-icon="exclamation-triangle"
data-prefix="fas"
focusable="false"
role="img"
style={{}}
viewBox="0 0 512 512"
style={Object {}}
viewBox="0 0 576 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480L40 480c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24l0 112c0 13.3 10.7 24 24 24s24-10.7 24-24l0-112c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
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={{}}
style={Object {}}
/>
</svg>
</div>
<div>
Before proceeding, please
<a
className="pgn__hyperlink default-link standalone-link"
href="https://help.edx.org/edxlearner/s/article/How-do-I-link-or-unlink-my-edX-account-to-a-social-media-account"
target="_self"
>
unlink all social media accounts
</a>
.
<span>
Before proceeding, please
<a
className="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>
</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 2`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 3`] = `null`;
exports[`SuccessModal should match default closed success modal snapshot 4`] = `null`;
exports[`SuccessModal should match open success modal snapshot 1`] = `
[
exports[`SuccessModal should match default closed success modal snapshot 1`] = `
Array [
<div
data-focus-guard={true}
style={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
className="fade"
role="presentation"
/>,
<div
className="pgn__modal-layer"
data-focus-lock-disabled="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onWheelCapture={[Function]}
className="modal fade"
role="presentation"
>
<div
className="pgn__modal-content-container"
aria-labelledby="id1"
aria-modal={true}
className=""
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={-1}
/>
<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="disabled"
onBlur={[Function]}
onFocus={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[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="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>
<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={-1}
/>
</div>
</div>,
]
`;
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`] = `
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`] = `
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
className="modal-backdrop show"
role="presentation"
/>,
<div
className="modal show d-block"
role="presentation"
>
<div
aria-labelledby="id5"
aria-modal={true}
className="modal-dialog"
role="dialog"
>
<div
data-focus-guard={true}
style={
Object {
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={0}
/>
<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={false}
onBlur={[Function]}
onFocus={[Function]}
onMouseDown={[Function]}
onScrollCapture={[Function]}
onTouchMoveCapture={[Function]}
onTouchStart={[Function]}
onWheelCapture={[Function]}
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<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>
</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={
{
"height": "0px",
"left": "1px",
"overflow": "hidden",
"padding": 0,
"position": "fixed",
"top": "1px",
"width": "1px",
}
}
tabIndex={-1}
/>,
]
`;

View File

@@ -1,65 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { handleRequestError } from '../../data/utils';
import { postDeleteAccount } from './service';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/auth');
jest.mock('form-urlencoded');
jest.mock('../../data/utils');
describe('postDeleteAccount', () => {
const mockPost = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
getConfig.mockReturnValue({
LMS_BASE_URL: 'http://testserver',
});
getAuthenticatedHttpClient.mockReturnValue({
post: mockPost,
});
formurlencoded.mockImplementation(obj => `encoded:${JSON.stringify(obj)}`);
});
it('posts delete account request with password', async () => {
const mockResponse = { data: { success: true } };
mockPost.mockResolvedValueOnce(mockResponse);
const result = await postDeleteAccount('mypassword');
expect(getConfig).toHaveBeenCalled();
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(formurlencoded).toHaveBeenCalledWith({ password: 'mypassword' });
expect(mockPost).toHaveBeenCalledWith(
'http://testserver/api/user/v1/accounts/deactivate_logout/',
'encoded:{"password":"mypassword"}',
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
expect(result).toEqual(mockResponse.data);
});
it('calls handleRequestError and throws when request fails', async () => {
const mockError = new Error('Request failed');
mockPost.mockRejectedValueOnce(mockError);
handleRequestError.mockImplementation(() => {
throw mockError;
});
await expect(postDeleteAccount('wrongpassword')).rejects.toThrow('Request failed');
expect(handleRequestError).toHaveBeenCalledWith(mockError);
});
});

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

@@ -1,11 +1,6 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.cannot.delete.account.text': {
id: 'account.settings.cannot.delete.account.text',
defaultMessage: 'Please note that, for legal and regulatory compliance purposes, account deletion is currently unavailable.',
description: 'This text is visible when user is not allowed to delete account',
},
'account.settings.delete.account.header': {
id: 'account.settings.delete.account.header',
defaultMessage: 'Delete My Account',
@@ -56,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

@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { CheckBox } from '@edx/paragon';
import { DECLINED } from '../data/constants';
const Checkboxes = (props) => {
const {
id,
options,
values,
onChange,
} = props;
const [selected, setSelected] = useState(values);
useEffect(() => {
onChange(id, selected);
}, [selected]);
const handleToggle = (value, option) => {
// If the user checked 'declined', uncheck all other options
if (value && option === DECLINED) {
setSelected([DECLINED]);
return;
}
// If option checked, make sure this option is in `selected` (and remove 'declined')
if (value && !selected.includes(option)) {
const newSelected = selected.filter(i => i !== DECLINED).concat(option);
setSelected(newSelected);
}
// If unchecked, make sure this option is NOT in `selected`
if (!value) {
setSelected(selected.filter(i => i !== option));
}
};
const renderCheckboxes = () => options.map((option, index) => {
const isFirst = index === 0;
const isChecked = selected.includes(option.value);
return (
<div key={option.value} className="checkboxOption">
<CheckBox
type="checkbox"
id={option.value}
name={option.value}
value={option.value}
checked={isChecked}
autoFocus={isFirst}
label={option.label}
onChange={(value) => handleToggle(value, option.value)}
/>
</div>
);
});
return (
<div role="group">
{renderCheckboxes()}
</div>
);
};
Checkboxes.propTypes = {
id: PropTypes.string.isRequired,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
label: PropTypes.string,
})),
values: PropTypes.arrayOf(PropTypes.string),
onChange: PropTypes.func.isRequired,
};
Checkboxes.defaultProps = {
options: [],
values: [],
};
export default Checkboxes;

View File

@@ -0,0 +1,357 @@
import { getConfig } from '@edx/frontend-platform';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink, Input } from '@edx/paragon';
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import get from 'lodash.get';
import isEmpty from 'lodash.isempty';
import memoize from 'memoize-one';
import { demographicsSectionSelector } from '../data/selectors';
import EditableField from '../EditableField';
import Checkboxes from './Checkboxes';
import Alert from '../Alert';
import { saveMultipleSettings, updateDraft } from '../data/actions';
import {
OTHER,
SELF_DESCRIBE,
} from '../data/constants';
import messages from './DemographicsSection.messages';
class DemographicsSection extends React.Component {
// We check the `demographicsOptions` prop to see if it is empty before we attempt to extract and
// format the available options for each question from the API response.
getApiOptions = memoize((demographicsOptions) => (this.hasRetrievedDemographicsOptions() && {
demographicsGenderOptions: this.addDefaultOption('account.settings.field.demographics.gender.options.empty')
.concat(demographicsOptions.actions.POST.gender.choices.map(key => ({
value: key.value,
label: key.display_name,
}))),
/* Ethnicity options don't need the blank/default option */
demographicsEthnicityOptions: demographicsOptions.actions.POST.user_ethnicity.child.children.ethnicity.choices.map(
key => ({
value: key.value,
label: key.display_name,
}),
),
demographicsIncomeOptions: this.addDefaultOption('account.settings.field.demographics.income.options.empty')
.concat(demographicsOptions.actions.POST.income.choices.map(key => ({
value: key.value,
label: key.display_name,
}))),
demographicsMilitaryHistoryOptions: this.addDefaultOption('account.settings.field.demographics.military_history.options.empty')
.concat(demographicsOptions.actions.POST.military_history.choices.map(key => ({
value: key.value,
label: key.display_name,
}))),
demographicsEducationLevelOptions: this.addDefaultOption('account.settings.field.demographics.education_level.options.empty')
.concat(demographicsOptions.actions.POST.learner_education_level.choices.map(key => ({
value: key.value,
label: key.display_name,
}))),
demographicsWorkStatusOptions: this.addDefaultOption('account.settings.field.demographics.work_status.options.empty')
.concat(demographicsOptions.actions.POST.work_status.choices.map(key => ({
value: key.value,
label: key.display_name,
}))),
demographicsWorkSectorOptions: this.addDefaultOption('account.settings.field.demographics.work_sector.options.empty')
.concat(demographicsOptions.actions.POST.current_work_sector.choices.map(key => ({
value: key.value,
label: key.display_name,
}))),
}));
ethnicityFieldDisplay = (demographicsEthnicityOptions) => {
let ethnicities = [];
if (get(this, 'props.formValues.demographics_user_ethnicity')) {
ethnicities = this.props.formValues.demographics_user_ethnicity;
}
return ethnicities.map((e) => {
const matchingOption = demographicsEthnicityOptions.filter(option => option.value === e)[0];
return matchingOption && matchingOption.label;
}).join(', ');
}
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
};
handleSubmit = (formId) => {
// We have some custom fields in this section. Instead of relying on the
// submitted values, submit the values stored in 'drafts'.
const { drafts } = this.props;
const settingsArray = Object.entries(drafts).map(([field, value]) => ({
formId: field,
commitValues: value,
}));
this.props.saveMultipleSettings(settingsArray, formId);
};
/**
* Utility method that adds the specified message as a default option to the list of available
* choices.
*
* @param {*} messageId id of message matching desired default label text
*/
addDefaultOption(messageId) {
return [{
value: '',
label: this.props.intl.formatMessage(messages[messageId]),
}];
}
/**
* Utility method that helps determine if we were able to retrieve the available options for
* the Demographics questions. Returns true if the `demographicsOptions` prop is _not_ empty,
* otherwise false. This prop being empty is indicative of a failure communicating with the
* Demographics IDA's API.
*/
hasRetrievedDemographicsOptions() {
return !isEmpty(this.props.formValues.demographicsOptions);
}
/**
* If an error is encountered when trying to communicate with the Demographics IDA then we will
* display an Alert letting the user know that their info will not be displayed and temporarily
* cannot be updated.
*/
renderDemographicsServiceIssueWarning() {
if (!isEmpty(this.props.formErrors.demographicsError)
|| !this.hasRetrievedDemographicsOptions()) {
return (
<div
tabIndex="-1"
ref={this.alertRef}
>
<Alert className="alert alert-danger" role="alert">
<FormattedMessage
id="account.settings.message.demographics.service.issue"
defaultMessage="An error occurred attempting to retrieve or save your account information. Please try again later."
description="alert message informing the user that the there is a problem retrieving or updating information from the Demographics microservice"
/>
</Alert>
</div>
);
}
return null;
}
render() {
const editableFieldProps = {
onChange: this.handleEditableFieldChange,
onSubmit: this.handleSubmit,
};
const {
demographicsGenderOptions,
demographicsEthnicityOptions,
demographicsIncomeOptions,
demographicsMilitaryHistoryOptions,
demographicsEducationLevelOptions,
demographicsWorkStatusOptions,
demographicsWorkSectorOptions,
} = this.getApiOptions(this.props.formValues.demographicsOptions);
const showSelfDescribe = this.props.formValues.demographics_gender === SELF_DESCRIBE;
const showWorkStatusDescribe = this.props.formValues.demographics_work_status === OTHER;
return (
<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>
<Hyperlink
destination={`${getConfig().MARKETING_SITE_BASE_URL}/demographics`}
target="_blank"
rel="noopener noreferrer"
>
{this.props.intl.formatMessage(
messages['account.settings.section.demographics.why'],
{
siteName: getConfig().SITE_NAME,
},
)}
</Hyperlink>
</p>
{this.renderDemographicsServiceIssueWarning()}
{/*
If the demographicsOptions props are empty then there is no need to display the fields as
the user will not have any choices available to select, nor will they be able to update
their answers.
*/}
{this.hasRetrievedDemographicsOptions() && (
<div id="demographics-fields">
<EditableField
name="demographics_gender"
type="select"
value={this.props.formValues.demographics_gender}
userSuppliedValue={showSelfDescribe ? this.props.formValues.demographics_gender_description : null}
options={demographicsGenderOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender.empty'])}
{...editableFieldProps}
>
{showSelfDescribe && (
<Input
name="demographics_gender_description"
id="field-demographics_gender_description"
type="text"
placeholder={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender_description.empty'])}
value={this.props.formValues.demographics_gender_description}
onChange={(e) => this.handleEditableFieldChange('demographics_gender_description', e.target.value)}
aria-label={this.props.intl.formatMessage(messages['account.settings.field.demographics.gender_description'])}
className="mt-1"
/>
)}
</EditableField>
<EditableField
name="demographics_user_ethnicity"
type="select"
hidden
value={this.ethnicityFieldDisplay(demographicsEthnicityOptions)}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.ethnicity'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.ethnicity.empty'])}
{...editableFieldProps}
>
<Checkboxes
id="demographics_user_ethnicity"
options={demographicsEthnicityOptions}
values={this.props.formValues.demographics_user_ethnicity}
{...editableFieldProps}
/>
</EditableField>
<EditableField
name="demographics_income"
type="select"
value={this.props.formValues.demographics_income}
options={demographicsIncomeOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.income'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.income.empty'])}
{...editableFieldProps}
/>
<EditableField
name="demographics_military_history"
type="select"
value={this.props.formValues.demographics_military_history}
options={demographicsMilitaryHistoryOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.military_history'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.military_history.empty'])}
{...editableFieldProps}
/>
<EditableField
name="demographics_learner_education_level"
type="select"
value={this.props.formValues.demographics_learner_education_level}
options={demographicsEducationLevelOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.learner_education_level'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.learner_education_level.empty'])}
{...editableFieldProps}
/>
<EditableField
name="demographics_parent_education_level"
type="select"
value={this.props.formValues.demographics_parent_education_level}
options={demographicsEducationLevelOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.parent_education_level'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.parent_education_level.empty'])}
{...editableFieldProps}
/>
<EditableField
name="demographics_work_status"
type="select"
value={this.props.formValues.demographics_work_status}
userSuppliedValue={showWorkStatusDescribe
? this.props.formValues.demographics_work_status_description
: null}
options={demographicsWorkStatusOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status.empty'])}
{...editableFieldProps}
>
{showWorkStatusDescribe && (
<Input
name="demographics_work_status_description"
id="field-demographics_work_status_description"
type="text"
placeholder={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status_description.empty'])}
value={this.props.formValues.demographics_work_status_description}
onChange={(e) => this.handleEditableFieldChange('demographics_work_status_description', e.target.value)}
aria-label={this.props.intl.formatMessage(messages['account.settings.field.demographics.work_status_description'])}
className="mt-1"
/>
)}
</EditableField>
<EditableField
name="demographics_current_work_sector"
type="select"
value={this.props.formValues.demographics_current_work_sector}
options={demographicsWorkSectorOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.current_work_sector'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.current_work_sector.empty'])}
{...editableFieldProps}
/>
<EditableField
name="demographics_future_work_sector"
type="select"
value={this.props.formValues.demographics_future_work_sector}
options={demographicsWorkSectorOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.demographics.future_work_sector'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.demographics.future_work_sector.empty'])}
{...editableFieldProps}
/>
</div>
)}
</div>
);
}
}
DemographicsSection.propTypes = {
intl: intlShape.isRequired,
formValues: PropTypes.shape({
demographics_gender: PropTypes.string,
demographics_user_ethnicity: PropTypes.array,
demographics_income: PropTypes.string,
demographics_military_history: PropTypes.string,
demographics_learner_education_level: PropTypes.string,
demographics_parent_education_level: PropTypes.string,
demographics_work_status: PropTypes.string,
demographics_current_work_sector: PropTypes.string,
demographics_future_work_sector: PropTypes.string,
demographics_work_status_description: PropTypes.string,
demographics_gender_description: PropTypes.string,
demographicsOptions: PropTypes.object,
}).isRequired,
drafts: PropTypes.shape({
demographics_gender: PropTypes.string,
demographics_user_ethnicity: PropTypes.array,
demographics_income: PropTypes.string,
demographics_military_history: PropTypes.string,
demographics_learner_education_level: PropTypes.string,
demographics_parent_education_level: PropTypes.string,
demographics_work_status: PropTypes.string,
demographics_current_work_sector: PropTypes.string,
demographics_future_work_sector: PropTypes.string,
demographics_work_status_description: PropTypes.string,
demographics_gender_description: PropTypes.string,
demographicsOptions: PropTypes.object,
}).isRequired,
formErrors: PropTypes.shape({
demographicsError: PropTypes.string,
}).isRequired,
forwardRef: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
saveMultipleSettings: PropTypes.func.isRequired,
};
export default connect(demographicsSectionSelector, {
saveMultipleSettings,
updateDraft,
})(injectIntl(DemographicsSection));

View File

@@ -0,0 +1,170 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
/* Demographics section heading */
'account.settings.section.demographics.information': {
id: 'account.settings.section.demographics.information',
defaultMessage: 'Optional Information',
description: 'The optional information section heading.',
},
/* Gender identity */
'account.settings.field.demographics.gender': {
id: 'account.settings.field.demographics.gender',
defaultMessage: 'Gender identity',
description: 'Label for account settings gender identity field.',
},
'account.settings.field.demographics.gender.empty': {
id: 'account.settings.field.demographics.gender.empty',
defaultMessage: 'Add gender identity',
description: 'Placeholder for empty account settings gender identity field.',
},
'account.settings.field.demographics.gender.options.empty': {
id: 'account.settings.field.demographics.gender.options.empty',
defaultMessage: 'Select a gender identity',
description: 'Placeholder for the gender identity options dropdown.',
},
'account.settings.field.demographics.gender_description': {
id: 'account.settings.field.demographics.gender_description',
defaultMessage: 'Gender identity description',
description: 'Label for account settings gender identity description field.',
},
'account.settings.field.demographics.gender_description.empty': {
id: 'account.settings.field.demographics.gender_description.empty',
defaultMessage: 'Enter description',
description: 'Placeholder for empty account settings gender identity field.',
},
/* Ethnicity */
'account.settings.field.demographics.ethnicity': {
id: 'account.settings.field.demographics.ethnicity',
defaultMessage: 'Race/Ethnicity identity',
description: 'Label for account settings ethnic background field.',
},
'account.settings.field.demographics.ethnicity.empty': {
id: 'account.settings.field.demographics.ethnicity.empty',
defaultMessage: 'Add race/ethnicity identity',
description: 'Placeholder for empty account settings ethnic background field.',
},
'account.settings.field.demographics.ethnicity.options.empty': {
id: 'account.settings.field.demographics.ethnicity.options.empty',
defaultMessage: 'Select all that apply', // TODO: Is this the desired text?
description: 'Placeholder for the ethnic background options field.',
},
/* Income */
'account.settings.field.demographics.income': {
id: 'account.settings.field.demographics.income',
defaultMessage: 'Family income',
description: 'Label for account settings household income field.',
},
'account.settings.field.demographics.income.empty': {
id: 'account.settings.field.demographics.income.empty',
defaultMessage: 'Add family income',
description: 'Placeholder for empty account settings household income field.',
},
'account.settings.field.demographics.income.options.empty': {
id: 'account.settings.field.demographics.income.options.empty',
defaultMessage: 'Select a family income range',
description: 'Placeholder for the household income dropdown.',
},
/* Military history */
'account.settings.field.demographics.military_history': {
id: 'account.settings.field.demographics.military_history',
defaultMessage: 'U.S. Military status',
description: 'Label for account settings military history field.',
},
'account.settings.field.demographics.military_history.empty': {
id: 'account.settings.field.demographics.military_history.empty',
defaultMessage: 'Add military status',
description: 'Placeholder for empty account settings military history field.',
},
'account.settings.field.demographics.military_history.options.empty': {
id: 'account.settings.field.demographics.military_history.options.empty',
defaultMessage: 'Select military status',
description: 'Placeholder for the military history dropdown.',
},
/* Learner and family education level */
'account.settings.field.demographics.learner_education_level': {
id: 'account.settings.field.demographics.learner_education_level',
defaultMessage: 'Your education level',
description: 'Label for account settings learner education level field.',
},
'account.settings.field.demographics.learner_education_level.empty': {
id: 'account.settings.field.demographics.learner_education_level.empty',
defaultMessage: 'Add education level',
description: 'Placeholder for empty account settings learner education level field.',
},
'account.settings.field.demographics.parent_education_level': {
id: 'account.settings.field.demographics.parent_education_level',
defaultMessage: 'Parents/Guardians education level',
description: 'Label for account settings parent education level field.',
},
'account.settings.field.demographics.parent_education_level.empty': {
id: 'account.settings.field.demographics.parent_education_level.empty',
defaultMessage: 'Add education level',
description: 'Placeholder for empty account settings parent education level field.',
},
'account.settings.field.demographics.education_level.options.empty': {
id: 'account.settings.field.demographics.education_level.options.empty',
defaultMessage: 'Select education level',
description: 'Placeholder for the education level options dropdown.',
},
/* Work status */
'account.settings.field.demographics.work_status': {
id: 'account.settings.field.demographics.work_status',
defaultMessage: 'Employment status',
description: 'Label for account settings work status field.',
},
'account.settings.field.demographics.work_status.empty': {
id: 'account.settings.field.demographics.work_status.empty',
defaultMessage: 'Add employment status',
description: 'Placeholder for empty account settings work status field.',
},
'account.settings.field.demographics.work_status.options.empty': {
id: 'account.settings.field.demographics.work_status.options.empty',
defaultMessage: 'Select employment status',
description: 'Placeholder for the work status options dropdown.',
},
'account.settings.field.demographics.work_status_description': {
id: 'account.settings.field.demographics.work_status_description',
defaultMessage: 'Employment status description',
description: 'Label for account settings work status description field.',
},
'account.settings.field.demographics.work_status_description.empty': {
id: 'account.settings.field.demographics.work_status_description.empty',
defaultMessage: 'Enter description',
description: 'Placeholder for empty account settings work status description field.',
},
/* Work sector */
'account.settings.field.demographics.current_work_sector': {
id: 'account.settings.field.demographics.current_work_sector',
defaultMessage: 'Current work industry',
description: 'Label for account settings current work sector field.',
},
'account.settings.field.demographics.current_work_sector.empty': {
id: 'account.settings.field.demographics.current_work_sector.empty',
defaultMessage: 'Add work industry',
description: 'Placeholder for empty account settings current work sector field.',
},
'account.settings.field.demographics.future_work_sector': {
id: 'account.settings.field.demographics.future_work_sector',
defaultMessage: 'Future work industry',
description: 'Label for account settings future work sector field.',
},
'account.settings.field.demographics.future_work_sector.empty': {
id: 'account.settings.field.demographics.future_work_sector.empty',
defaultMessage: 'Add work industry',
description: 'Placeholder for empty account settings future work sector field.',
},
'account.settings.field.demographics.work_sector.options.empty': {
id: 'account.settings.field.demographics.work_sector.options.empty',
defaultMessage: 'Select work industry',
description: 'Placeholder for the work sector options dropdown.',
},
/* Legal copy link text */
'account.settings.section.demographics.why': {
id: 'account.settings.section.demographics.why',
defaultMessage: 'Why does {siteName} collect this information?',
description: 'Link text for a link to external legal text',
},
});
export default messages;

View File

@@ -0,0 +1,140 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import get from 'lodash.get';
import { convertData, TO, FROM } from './utils';
/**
* Utility method that attempts to extract errors from the response of a PATCH request in order to
* display a warning or otherwise meaningful message to the user.
*
* @param {Error} error
*/
export function createDemographicsError(error) {
const apiError = Object.create(error);
// If the error received has the `httpResponseData` field in it, then we should have reason to
// believe the Demographics service is alive and responding. Extract errors from fields where
// appropriate so we can display them to the user.
if (get(error, 'customAttributes.httpErrorResponseData')) {
apiError.fieldErrors = JSON.parse(error.customAttributes.httpErrorResponseData);
if (get(apiError, 'fieldErrors.gender_description')) {
// eslint-disable-next-line prefer-destructuring
apiError.fieldErrors.demographics_gender = apiError.fieldErrors.gender_description[0];
delete apiError.fieldErrors.gender_description;
} else if (get(apiError, 'fieldErrors.work_status_description')) {
// eslint-disable-next-line prefer-destructuring
apiError.fieldErrors.demographics_work_status = apiError.fieldErrors.work_status_description[0];
delete apiError.fieldErrors.work_status_description;
}
// Otherwise, when the service is down, the error response will not contain a
// `httpErrorResponseData` field. Add a generic 'demographicsError' field to the fieldErrors that
// will trigger showing an Alert to the user to them them know the update was unsuccessful.
} else {
apiError.fieldErrors = {
demographicsError: error.customAttributes.httpErrorType,
};
}
return apiError;
}
/**
* post all of the data related to demographics.
* @param {Number} userId users are identified in the api by LMS id
* @param {Object} commitValues { demographics }
*/
export async function postDemographics(userId) {
const requestConfig = { headers: { 'Content-Type': 'application/json' } };
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/`;
const commitValues = { user: userId };
let data = {};
({ data } = await getAuthenticatedHttpClient()
.post(requestUrl, commitValues, requestConfig)
.catch((error) => {
const apiError = createDemographicsError(error);
throw apiError;
}));
return convertData(data, FROM);
}
/**
* get all data related to the demographics.
* @param {Number} userId users are identified in the api by LMS id
*/
export async function getDemographics(userId) {
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/${userId}/`;
let data = {};
try {
({ data } = await getAuthenticatedHttpClient()
.get(requestUrl));
data = convertData(data, FROM);
} catch (error) {
const apiError = Object.create(error);
// if the API called resulted in this user receiving a 404 then follow up with a POST call to
// try and create the demographics entity on the backend
if (apiError.customAttributes.httpErrorStatus) {
if (apiError.customAttributes.httpErrorStatus === 404) {
data = await postDemographics(userId);
}
} else {
data = {
user: userId,
demographics_gender: '',
demographics_gender_description: '',
demographics_income: '',
demographics_learner_education_level: '',
demographics_parent_education_level: '',
demographics_military_history: '',
demographics_work_status: '',
demographics_work_status_description: '',
demographics_current_work_sector: '',
demographics_future_work_sector: '',
demographics_user_ethnicity: [],
};
}
}
return data;
}
/**
* patch all of the data related to demographics.
* @param {Number} userId users are identified in the api by LMS id
* @param {Object} commitValues { demographics }
*/
export async function patchDemographics(userId, commitValues) {
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/${userId}/`;
const convertedCommitValues = convertData(commitValues, TO);
let data = {};
({ data } = await getAuthenticatedHttpClient()
.patch(requestUrl, convertedCommitValues)
.catch((error) => {
const apiError = createDemographicsError(error);
throw apiError;
}));
return convertData(data, FROM);
}
/**
* retrieve the options for each field from the Demographics API
*/
export async function getDemographicsOptions() {
const requestUrl = `${getConfig().DEMOGRAPHICS_BASE_URL}/demographics/api/v1/demographics/`;
let data = {};
try {
({ data } = await getAuthenticatedHttpClient().options(requestUrl));
} catch (error) {
// We are catching and suppressing errors here on purpose. If an error occurs during the
// getDemographicsOptions call we will pass back an empty `data` object. Downstream we make
// the assumption that if the demographicsOptions object is empty that there was an issue or
// error communicating with the service/API.
}
return data;
}

View File

@@ -0,0 +1,63 @@
export const TO = 'to';
export const FROM = 'from';
export const DEMOGRAPHICS_FIELDS = [
'demographics_gender',
'demographics_gender_description',
'demographics_income',
'demographics_learner_education_level',
'demographics_parent_education_level',
'demographics_military_history',
'demographics_work_status',
'demographics_work_status_description',
'demographics_current_work_sector',
'demographics_future_work_sector',
'demographics_user_ethnicity',
];
// Frontend wants (example):
// demographics_user_ethnicity: ["asian", "white", "other"]
//
// Demographics wants (example):
// user_ethnicity: [
// { ethnicity: "asian" },
// { ethnicity: "white" },
// { ethnicity: "other" }
// ]
function convertEthnicity(ethnicityData, direction) {
if (direction === FROM) {
return ethnicityData.map(e => e.ethnicity);
}
if (direction === TO) {
return ethnicityData.map(e => ({ ethnicity: e }));
}
return ethnicityData;
}
// Handles conversion of data to/from Demographics IDA to/from format needed for
// frontend
// * handles ethnicity field
// * adds/removes 'demographics' to/from key
// * replace `null` with empty string or empty string with null
export function convertData(dataObject, direction) {
const converted = {};
Object.entries(dataObject).forEach(([key, value]) => {
let newValue = value;
if (key.includes('ethnicity')) {
newValue = convertEthnicity(value, direction);
}
if (direction === TO) {
converted[key.replace('demographics_', '')] = newValue || null;
}
if (direction === FROM) {
converted[`demographics_${key}`] = newValue || '';
}
});
return converted;
}

View File

@@ -0,0 +1,583 @@
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { Provider } from 'react-redux';
import React from 'react';
import configureStore from 'redux-mock-store';
import renderer from 'react-test-renderer';
import DemographicsSection from '../DemographicsSection';
jest.mock('@edx/frontend-platform/auth');
const IntlDemographicsSection = injectIntl(DemographicsSection);
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ demographicsSectionSelector: () => ({}) })));
const mockStore = configureStore();
describe('DemographicsSection', () => {
let props = {};
let store = {};
const reduxWrapper = children => (
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
);
beforeEach(() => {
store = mockStore();
props = {
updateDraft: jest.fn(),
formValues: {
demographics_gender: 'declined',
demographics_gender_description: '',
demographics_user_ethnicity: [],
demographics_income: 'declined',
demographics_military_history: 'declined',
demographics_learner_education_level: 'declined',
demographics_parent_education_level: 'declined',
demographics_work_status: 'declined',
demographics_work_status_description: '',
demographics_current_work_sector: 'declined',
demographics_future_work_sector: 'declined',
demographics_user: 1,
demographicsOptions: {
actions: {
POST: {
gender: {
choices: [
{
value: 'woman',
display_name: 'Woman',
},
{
value: 'man',
display_name: 'Man',
},
{
value: 'non-binary',
display_name: 'Non-binary',
},
{
value: 'self-describe',
display_name: 'Prefer to self describe',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
income: {
choices: [
{
value: 'less-than-10k',
display_name: 'Less than US $10,000',
},
{
value: '10k-25k',
display_name: 'US $10,000 - $25,000',
},
{
value: '25k-50k',
display_name: 'US $25,000 - $50,000',
},
{
value: '50k-75k',
display_name: 'US $50,000 - $75,000',
},
{
value: '75k-100k',
display_name: 'US $75,000 - $100,000',
},
{
value: 'over-100k',
display_name: 'Over US $100,000',
},
{
value: 'unsure',
display_name: "I don't know",
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
learner_education_level: {
choices: [
{
value: 'no-high-school',
display_name: 'No High School',
},
{
value: 'some-high-school',
display_name: 'Some High School',
},
{
value: 'high-school-ged-equivalent',
display_name: 'High School diploma, GED, or equivalent',
},
{
value: 'some-college',
display_name: 'Some college, but no degree',
},
{
value: 'associates',
display_name: 'Associates degree',
},
{
value: 'bachelors',
display_name: 'Bachelors degree',
},
{
value: 'masters',
display_name: 'Masters degree',
},
{
value: 'professional',
display_name: 'Professional degree',
},
{
value: 'doctorate',
display_name: 'Doctorate degree',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
parent_education_level: {
choices: [
{
value: 'no-high-school',
display_name: 'No High School',
},
{
value: 'some-high-school',
display_name: 'Some High School',
},
{
value: 'high-school-ged-equivalent',
display_name: 'High School diploma, GED, or equivalent',
},
{
value: 'some-college',
display_name: 'Some college, but no degree',
},
{
value: 'associates',
display_name: 'Associates degree',
},
{
value: 'bachelors',
display_name: 'Bachelors degree',
},
{
value: 'masters',
display_name: 'Masters degree',
},
{
value: 'professional',
display_name: 'Professional degree',
},
{
value: 'doctorate',
display_name: 'Doctorate degree',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
military_history: {
choices: [
{
value: 'never-served',
display_name: 'Never served in the military',
},
{
value: 'training',
display_name: 'Only on active duty for training',
},
{
value: 'active',
display_name: 'Now on active duty',
},
{
value: 'previously-active',
display_name: 'On active duty in the past, but not now',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
work_status: {
choices: [
{
value: 'full-time',
display_name: 'Employed, working full-time',
},
{
value: 'part-time',
display_name: 'Employed, working part-time',
},
{
value: 'self-employed',
display_name: 'Self-Employed',
},
{
value: 'not-employed-looking',
display_name: 'Not employed, looking for work',
},
{
value: 'not-employed-not-looking',
display_name: 'Not employed, not looking for work',
},
{
value: 'unable',
display_name: 'Unable to work',
},
{
value: 'retired',
display_name: 'Retired',
},
{
value: 'other',
display_name: 'Other',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
current_work_sector: {
choices: [
{
value: 'accommodation-food',
display_name: 'Accommodation and Food Services',
},
{
value: 'administrative-support-waste-remediation',
display_name: 'Administrative and Support and Waste Management and Remediation Services',
},
{
value: 'agriculture-forestry-fishing-hunting',
display_name: 'Agriculture, Forestry, Fishing and Hunting',
},
{
value: 'arts-entertainment-recreation',
display_name: 'Arts, Entertainment, and Recreation',
},
{
value: 'construction',
display_name: 'Construction',
},
{
value: 'educational',
display_name: 'Education Services',
},
{
value: 'finance-insurance',
display_name: 'Finance and Insurance',
},
{
value: 'healthcare-social',
display_name: 'Health Care and Social Assistance',
},
{
value: 'information',
display_name: 'Information',
},
{
value: 'management',
display_name: 'Management of Companies and Enterprises',
},
{
value: 'manufacturing',
display_name: 'Manufacturing',
},
{
value: 'mining-quarry-oil-gas',
display_name: 'Mining, Quarrying, and Oil and Gas Extraction',
},
{
value: 'professional-scientific-technical',
display_name: 'Professional, Scientific, and Technical Services',
},
{
value: 'public-admin',
display_name: 'Public Administration',
},
{
value: 'real-estate',
display_name: 'Real Estate and Rental and Leasing',
},
{
value: 'retail',
display_name: 'Retail Trade',
},
{
value: 'transport-warehousing',
display_name: 'Transportation and Warehousing',
},
{
value: 'utilities',
display_name: 'Utilities',
},
{
value: 'trade',
display_name: 'Wholesale Trade',
},
{
value: 'other',
display_name: 'Other',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
future_work_sector: {
choices: [
{
value: 'accommodation-food',
display_name: 'Accommodation and Food Services',
},
{
value: 'administrative-support-waste-remediation',
display_name: 'Administrative and Support and Waste Management and Remediation Services',
},
{
value: 'agriculture-forestry-fishing-hunting',
display_name: 'Agriculture, Forestry, Fishing and Hunting',
},
{
value: 'arts-entertainment-recreation',
display_name: 'Arts, Entertainment, and Recreation',
},
{
value: 'construction',
display_name: 'Construction',
},
{
value: 'educational',
display_name: 'Education Services',
},
{
value: 'finance-insurance',
display_name: 'Finance and Insurance',
},
{
value: 'healthcare-social',
display_name: 'Health Care and Social Assistance',
},
{
value: 'information',
display_name: 'Information',
},
{
value: 'management',
display_name: 'Management of Companies and Enterprises',
},
{
value: 'manufacturing',
display_name: 'Manufacturing',
},
{
value: 'mining-quarry-oil-gas',
display_name: 'Mining, Quarrying, and Oil and Gas Extraction',
},
{
value: 'professional-scientific-technical',
display_name: 'Professional, Scientific, and Technical Services',
},
{
value: 'public-admin',
display_name: 'Public Administration',
},
{
value: 'real-estate',
display_name: 'Real Estate and Rental and Leasing',
},
{
value: 'retail',
display_name: 'Retail Trade',
},
{
value: 'transport-warehousing',
display_name: 'Transportation and Warehousing',
},
{
value: 'utilities',
display_name: 'Utilities',
},
{
value: 'trade',
display_name: 'Wholesale Trade',
},
{
value: 'other',
display_name: 'Other',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
user_ethnicity: {
child: {
children: {
ethnicity: {
choices: [
{
value: 'american-indian-or-alaska-native',
display_name: 'American Indian or Alaska Native',
},
{
value: 'asian',
display_name: 'Asian',
},
{
value: 'black-or-african-american',
display_name: 'Black or African American',
},
{
value: 'hispanic-latin-spanish',
display_name: 'Hispanic, Latin, or Spanish origin',
},
{
value: 'middle-eastern-or-north-african',
display_name: 'Middle Eastern or North African',
},
{
value: 'native-hawaiian-or-pacific-islander',
display_name: 'Native Hawaiian or Other Pacific Islander',
},
{
value: 'white',
display_name: 'White',
},
{
value: 'other',
display_name: 'Some other race, ethnicity, or origin',
},
{
value: 'declined',
display_name: 'Prefer not to respond',
},
],
},
},
},
},
},
},
},
},
formErrors: {},
intl: {},
forwardRef: () => {},
drafts: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
patch: async () => ({
data: { status: 200 },
catch: () => {},
}),
}));
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 1 }));
});
it('should render', () => {
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('should render an Alert if an error occurs', () => {
props = {
...props,
formErrors: {
demographicsError: 'api-error',
},
};
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('should set user input correctly when user provides gender self-description', () => {
props = {
...props,
formValues: {
...props.formValues,
demographics_gender: 'self-describe',
demographics_gender_description: 'test',
},
};
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('should set user input correctly when user provides answers to work_status question', () => {
props = {
...props,
formValues: {
...props.formValues,
demographics_work_status: 'other',
demographics_work_status_description: 'test',
},
};
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('should render ethnicity text correctly', () => {
props = {
...props,
formValues: {
...props.formValues,
demographics_user_ethnicity: ['asian'],
},
};
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('should render ethnicity correctly when multiple options are selected', () => {
props = {
...props,
formValues: {
...props.formValues,
demographics_user_ethnicity: ['hispanic-latin-spanish', 'white'],
},
};
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
it('should render an Alert when demographicsOptions props are empty', () => {
props = {
...props,
formValues: {
demographicsOptions: null,
},
};
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,3922 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DemographicsSection should render 1`] = `
<div
className="account-section"
id="demographics-information"
>
<h2
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="d-inline-block align-text-top"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
aria-label=""
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>
</span>
</a>
</p>
<div
id="demographics-fields"
>
<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"
>
Gender identity
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Race/Ethnicity identity
</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}
>
<button
className="p-0 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Add race/ethnicity identity
</button>
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Family income
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
U.S. Military status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Your education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Parents/Guardians education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Employment status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Current work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Future work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`DemographicsSection should render an Alert if an error occurs 1`] = `
<div
className="account-section"
id="demographics-information"
>
<h2
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="d-inline-block align-text-top"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
aria-label=""
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>
</span>
</a>
</p>
<div
tabIndex="-1"
>
<div
className="alert d-flex align-items-start alert alert-danger"
>
<div />
<div>
<span>
An error occurred attempting to retrieve or save your account information. Please try again later.
</span>
</div>
</div>
</div>
<div
id="demographics-fields"
>
<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"
>
Gender identity
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Race/Ethnicity identity
</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}
>
<button
className="p-0 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Add race/ethnicity identity
</button>
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Family income
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
U.S. Military status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Your education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Parents/Guardians education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Employment status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Current work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Future work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`DemographicsSection should render an Alert when demographicsOptions props are empty 1`] = `
<div
className="account-section"
id="demographics-information"
>
<h2
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="d-inline-block align-text-top"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
aria-label=""
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>
</span>
</a>
</p>
<div
tabIndex="-1"
>
<div
className="alert d-flex align-items-start alert alert-danger"
>
<div />
<div>
<span>
An error occurred attempting to retrieve or save your account information. Please try again later.
</span>
</div>
</div>
</div>
</div>
`;
exports[`DemographicsSection should render ethnicity correctly when multiple options are selected 1`] = `
<div
className="account-section"
id="demographics-information"
>
<h2
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="d-inline-block align-text-top"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
aria-label=""
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>
</span>
</a>
</p>
<div
id="demographics-fields"
>
<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"
>
Gender identity
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Race/Ethnicity identity
</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}
>
Hispanic, Latin, or Spanish origin, White
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Family income
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
U.S. Military status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Your education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Parents/Guardians education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Employment status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Current work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Future work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`DemographicsSection should render ethnicity text correctly 1`] = `
<div
className="account-section"
id="demographics-information"
>
<h2
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="d-inline-block align-text-top"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
aria-label=""
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>
</span>
</a>
</p>
<div
id="demographics-fields"
>
<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"
>
Gender identity
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Race/Ethnicity identity
</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}
>
Asian
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Family income
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
U.S. Military status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Your education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Parents/Guardians education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Employment status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Current work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Future work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`DemographicsSection should set user input correctly when user provides answers to work_status question 1`] = `
<div
className="account-section"
id="demographics-information"
>
<h2
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="d-inline-block align-text-top"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
aria-label=""
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>
</span>
</a>
</p>
<div
id="demographics-fields"
>
<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"
>
Gender identity
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Race/Ethnicity identity
</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}
>
<button
className="p-0 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Add race/ethnicity identity
</button>
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Family income
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
U.S. Military status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Your education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Parents/Guardians education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Employment status
</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}
>
Other: test
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Current work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Future work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
</div>
</div>
`;
exports[`DemographicsSection should set user input correctly when user provides gender self-description 1`] = `
<div
className="account-section"
id="demographics-information"
>
<h2
className="section-heading"
>
Optional Information
</h2>
<p>
<a
className="default-link standalone-link"
href="http://localhost:5335/demographics"
onClick={[Function]}
rel="noopener noopener noreferrer"
target="_blank"
>
Why does localhost collect this information?
<span
className="d-inline-block align-text-top"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
aria-label=""
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>
</span>
</a>
</p>
<div
id="demographics-fields"
>
<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"
>
Gender identity
</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}
>
Prefer to self describe: test
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Race/Ethnicity identity
</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}
>
<button
className="p-0 btn btn-link"
disabled={false}
onClick={[Function]}
type="button"
>
Add race/ethnicity identity
</button>
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Family income
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
U.S. Military status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Your education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Parents/Guardians education level
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Employment status
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Current work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
<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"
>
Future work industry
</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}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
</div>
</div>
`;

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,10 +1,10 @@
import { useCallback, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { connect, useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import PropTypes from 'prop-types';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Alert,
@@ -13,7 +13,7 @@ import {
Form,
ModalDialog,
StatefulButton,
} from '@openedx/paragon';
} from '@edx/paragon';
import { closeForm, saveSettingsReset } from '../data/actions';
import { nameChangeSelector } from '../data/selectors';
@@ -21,35 +21,35 @@ import { nameChangeSelector } from '../data/selectors';
import { requestNameChange, requestNameChangeFailure, requestNameChangeReset } from './data/actions';
import messages from './messages';
const NameChangeModal = ({
function NameChangeModal({
targetFormId,
errors,
formValues,
intl,
saveState,
}) => {
}) {
const dispatch = useDispatch();
const navigate = useNavigate();
const { push } = useHistory();
const { username } = getAuthenticatedUser();
const [verifiedNameInput, setVerifiedNameInput] = useState(formValues.verified_name || '');
const [confirmedWarning, setConfirmedWarning] = useState(false);
const intl = useIntl();
const resetLocalState = useCallback(() => {
function resetLocalState() {
setConfirmedWarning(false);
dispatch(requestNameChangeReset());
}, [dispatch]);
}
const handleChange = (e) => {
function handleChange(e) {
setVerifiedNameInput(e.target.value);
};
}
const handleClose = useCallback(() => {
function handleClose() {
resetLocalState();
dispatch(closeForm(targetFormId));
dispatch(saveSettingsReset());
}, [dispatch, resetLocalState, targetFormId]);
}
const handleSubmit = (e) => {
function handleSubmit(e) {
e.preventDefault();
if (saveState === 'pending') {
@@ -64,14 +64,14 @@ const NameChangeModal = ({
const draftProfileName = targetFormId === 'name' ? formValues.name : null;
dispatch(requestNameChange(username, draftProfileName, verifiedNameInput));
}
};
}
useEffect(() => {
if (saveState === 'complete') {
handleClose();
navigate(`/id-verification?next=${encodeURIComponent('account/settings')}`);
push('/id-verification');
}
}, [handleClose, navigate, saveState]);
}, [saveState]);
function renderErrors() {
if (Object.keys(errors).length > 0) {
@@ -183,7 +183,7 @@ const NameChangeModal = ({
</ModalDialog>
);
};
}
NameChangeModal.propTypes = {
targetFormId: PropTypes.string.isRequired,
@@ -193,10 +193,11 @@ NameChangeModal.propTypes = {
verified_name: PropTypes.string,
}).isRequired,
saveState: PropTypes.string,
intl: intlShape.isRequired,
};
NameChangeModal.defaultProps = {
saveState: null,
};
export default connect(nameChangeSelector)(NameChangeModal);
export default connect(nameChangeSelector)(injectIntl(NameChangeModal));

View File

@@ -1,56 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { handleRequestError } from '../../data/utils';
import { postNameChange } from './service';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/utils');
describe('postNameChange', () => {
const mockPost = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
getConfig.mockReturnValue({
LMS_BASE_URL: 'http://testserver',
});
getAuthenticatedHttpClient.mockReturnValue({
post: mockPost,
});
});
it('posts a name change request successfully', async () => {
const mockResponse = { data: { success: true, updated: true } };
mockPost.mockResolvedValueOnce(mockResponse);
const result = await postNameChange('New Name');
expect(getConfig).toHaveBeenCalled();
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(mockPost).toHaveBeenCalledWith(
'http://testserver/api/user/v1/accounts/name_change/',
{ name: 'New Name' },
{ headers: { Accept: 'application/json' } },
);
expect(result).toEqual(mockResponse.data);
});
it('calls handleRequestError and throws when request fails', async () => {
const mockError = new Error('Request failed');
mockPost.mockRejectedValueOnce(mockError);
handleRequestError.mockImplementation(() => {
throw mockError;
});
await expect(postNameChange('Bad Name')).rejects.toThrow('Request failed');
expect(handleRequestError).toHaveBeenCalledWith(mockError);
});
});

View File

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

View File

@@ -23,12 +23,12 @@ const messages = defineMessages({
},
'account.settings.name.change.id.name.label': {
id: 'account.settings.name.change.id.name.label',
defaultMessage: 'Enter your name as it appears on your identification card.',
defaultMessage: 'Enter your name as it appears on your government-issued ID.',
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',
defaultMessage: 'Enter the name on your government ID',
description: 'Form label instructing the user to enter the name on their ID.',
},
'account.settings.name.change.error.valid.name': {

View File

@@ -1,21 +1,20 @@
/* 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 { Router } from 'react-router-dom';
import configureStore from 'redux-mock-store';
import {
fireEvent,
render,
screen,
} from '@testing-library/react';
import { createMemoryHistory } from 'history';
import * as auth from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding createPortal allows portals to be tested in jest.
jest.mock('react-dom', () => ({
...jest.requireActual('react-dom'),
createPortal: jest.fn(node => node), // Mock portal behavior
}));
// 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
@@ -28,6 +27,10 @@ jest.mock('react-redux', () => ({
jest.mock('@edx/frontend-platform/auth');
jest.mock('../../data/selectors', () => jest.fn().mockImplementation(() => ({ nameChangeSelector: () => ({}) })));
const history = createMemoryHistory();
const IntlNameChange = injectIntl(NameChange);
const mockStore = configureStore();
describe('NameChange', () => {
@@ -35,7 +38,7 @@ describe('NameChange', () => {
let store = {};
const reduxWrapper = children => (
<Router>
<Router history={history}>
<IntlProvider locale="en">
<Provider store={store}>{children}</Provider>
</IntlProvider>
@@ -52,6 +55,7 @@ describe('NameChange', () => {
verified_name: 'edX Verified',
},
saveState: null,
intl: {},
};
auth.getAuthenticatedHttpClient = jest.fn(() => ({
@@ -66,9 +70,9 @@ describe('NameChange', () => {
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');
const getInput = () => screen.queryByPlaceholderText('Enter the name on your government ID');
render(reduxWrapper(<NameChange {...props} />));
render(reduxWrapper(<IntlNameChange {...props} />));
expect(getInput()).toBeNull();
const continueButton = screen.getByText('Continue');
@@ -78,14 +82,14 @@ describe('NameChange', () => {
});
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 getInput = () => screen.queryByPlaceholderText('Enter the name on your government ID');
const formProps = {
...props,
formValues: {
name: 'edx edx',
},
};
render(reduxWrapper(<NameChange {...formProps} />));
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
@@ -103,12 +107,12 @@ describe('NameChange', () => {
type: 'ACCOUNT_SETTINGS__REQUEST_NAME_CHANGE',
};
render(reduxWrapper(<NameChange {...props} />));
render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
const input = screen.getByPlaceholderText('Enter the name on your government ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
@@ -130,12 +134,12 @@ describe('NameChange', () => {
targetFormId: 'name',
};
render(reduxWrapper(<NameChange {...formProps} />));
render(reduxWrapper(<IntlNameChange {...formProps} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
const input = screen.getByPlaceholderText('Enter the name on your government ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
@@ -146,12 +150,12 @@ describe('NameChange', () => {
it('does not dispatch action while pending', async () => {
props.saveState = 'pending';
render(reduxWrapper(<NameChange {...props} />));
render(reduxWrapper(<IntlNameChange {...props} />));
const continueButton = screen.getByText('Continue');
fireEvent.click(continueButton);
const input = screen.getByPlaceholderText('Enter the name on your photo ID');
const input = screen.getByPlaceholderText('Enter the name on your government ID');
fireEvent.change(input, { target: { value: 'Verified Name' } });
const submitButton = screen.getByText('Continue');
@@ -162,7 +166,7 @@ describe('NameChange', () => {
it('routes to IDV when name change request is successful', async () => {
props.saveState = 'complete';
render(reduxWrapper(<NameChange {...props} />));
expect(window.location.pathname).toEqual('/id-verification');
render(reduxWrapper(<IntlNameChange {...props} />));
expect(history.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

@@ -1,7 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { StatefulButton } from '@openedx/paragon';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { StatefulButton } from '@edx/paragon';
import { resetPassword } from './data/actions';
import messages from './messages';
@@ -9,9 +10,7 @@ import ConfirmationAlert from './ConfirmationAlert';
import RequestInProgressAlert from './RequestInProgressAlert';
const ResetPassword = (props) => {
const { email, status } = props;
const intl = useIntl();
const { email, intl, status } = props;
return (
<div className="form-group">
<h6 aria-level="3">
@@ -52,6 +51,7 @@ const ResetPassword = (props) => {
ResetPassword.propTypes = {
email: PropTypes.string,
intl: intlShape.isRequired,
resetPassword: PropTypes.func.isRequired,
status: PropTypes.string,
};
@@ -68,4 +68,4 @@ export default connect(
{
resetPassword,
},
)(ResetPassword);
)(injectIntl(ResetPassword));

View File

@@ -1,65 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { handleRequestError } from '../../data/utils';
import { postResetPassword } from './service';
jest.mock('@edx/frontend-platform');
jest.mock('@edx/frontend-platform/auth');
jest.mock('form-urlencoded');
jest.mock('../../data/utils');
describe('postResetPassword', () => {
const mockPost = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
getConfig.mockReturnValue({
LMS_BASE_URL: 'http://testserver',
});
getAuthenticatedHttpClient.mockReturnValue({
post: mockPost,
});
formurlencoded.mockImplementation(obj => `encoded:${JSON.stringify(obj)}`);
});
it('posts reset password request with email', async () => {
const mockResponse = { data: { success: true, email_sent: true } };
mockPost.mockResolvedValueOnce(mockResponse);
const result = await postResetPassword('user@example.com');
expect(getConfig).toHaveBeenCalled();
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(formurlencoded).toHaveBeenCalledWith({ email: 'user@example.com' });
expect(mockPost).toHaveBeenCalledWith(
'http://testserver/password_reset/',
'encoded:{"email":"user@example.com"}',
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
expect(result).toEqual(mockResponse.data);
});
it('calls handleRequestError and throws when request fails', async () => {
const mockError = new Error('Reset password failed');
mockPost.mockRejectedValueOnce(mockError);
handleRequestError.mockImplementation(() => {
throw mockError;
});
await expect(postResetPassword('bad@example.com')).rejects.toThrow('Reset password failed');
expect(handleRequestError).toHaveBeenCalledWith(mockError);
});
});

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,61 +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,
},
{
code: 'te',
name: 'తెలుగు',
released: true,
},
{
code: 'da',
name: 'dansk',
released: true,
},
{
code: 'el',
name: 'Ελληνικά',
released: true,
},
{
code: 'es-es',
name: 'Español (España)',
released: true,
},
{
code: 'sw',
name: 'Kiswahili',
released: true,
},
{
code: 'tr-tr',
name: 'Türkçe (Türkiye)',
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

@@ -23,15 +23,10 @@ export async function patchPreferences(username, params) {
export async function postSetLang(code) {
const formData = new FormData();
const requestConfig = {
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
};
const url = `${getConfig().LMS_BASE_URL}/i18n/setlang/`;
formData.append('language', code);
await getAuthenticatedHttpClient()
.post(url, formData, requestConfig);
.post(`${getConfig().LMS_BASE_URL}/i18n/setlang/`, formData, {
headers: { 'X-Requested-With': 'XMLHttpRequest' },
});
}

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