Compare commits

...

249 Commits

Author SHA1 Message Date
morenol
8602561e55 fix: Update frontend-build (#340)
Update frontend-platform
2020-11-10 17:18:02 -05:00
Renovate Bot
b310574f18 fix(deps): update font awesome 2020-11-10 02:00:05 +00:00
Renovate Bot
73badaf916 fix(deps): update dependency universal-cookie to v4.0.4 2020-11-10 00:58:25 +00:00
Renovate Bot
f02cc43078 chore(deps): update dependency enzyme-adapter-react-16 to v1.15.5 2020-11-09 23:36:50 +00:00
edX Transifex Bot
bd5c2343be fix(i18n): update translations 2020-11-08 16:03:56 -05:00
alangsto
6349d487e4 Merge pull request #343 from edx/alangsto/camera_permissions
Ensure that first stream is stopped
2020-11-05 14:40:40 -05:00
Alie Langston
9c6137e668 testing to see if we need to request front/back camera
stopping initial stream
2020-11-05 14:30:39 -05:00
alangsto
2cf01270d7 Merge pull request #342 from edx/alangsto/fix_camera_access
Remove browser based resolution
2020-11-04 13:15:26 -05:00
Alie Langston
403df8926d removed browser based resolution, and instead optimized photo for all browsers
completed comment
2020-11-04 13:01:14 -05:00
Bianca Severino
4bd96c70af Merge pull request #341 from edx/bseverino/name-change-fix
Fix onSubmit for IDV name change form
2020-10-27 12:32:30 -04:00
Bianca Severino
d2a835f560 Fix submit functionality on name change form 2020-10-27 12:13:47 -04:00
Bianca Severino
d0b5d54d0a Merge pull request #339 from edx/bseverino/id-resolution
Increase camera resolution to 1080p for ID verification
2020-10-20 14:17:51 -04:00
Bianca Severino
26299eed65 Increase camera resolution to 1080p 2020-10-20 13:53:01 -04:00
adeelehsan
648bea8d84 Merge pull request #338 from edx/aehsan/suppressred_pii_for_accounts
suppressed pii for account
2020-10-09 01:59:13 +05:00
adeelehsan
7409f02056 suppressed pii for account 2020-10-09 00:54:07 +05:00
alangsto
f5dd409816 Merge pull request #337 from edx/alangsto/add_tracking_events
Add tracking events for face detection
2020-10-01 15:32:36 -04:00
Alie Langston
6ddac11dc0 added tracking events
added testing
2020-10-01 14:19:07 -04:00
adeelehsan
7019aea4fb Merge pull request #333 from edx/aehsan/van-23/suppressed_pii_for_accounts
suppressed pii for hot jar
2020-09-25 17:33:37 +05:00
adeelehsan
5424434599 suppressed pii for hot jar
VAN-23
2020-09-25 17:22:13 +05:00
edX Transifex Bot
8ca9dc78a9 fix(i18n): update translations 2020-09-20 17:06:06 -04:00
alangsto
30e25b96bb Merge pull request #332 from edx/alangsto/add_realtime_feedback
Add realtime feedback for screenreaders during face detection
2020-09-16 15:31:00 -04:00
Alie Langston
1d01abc7da added feedback for screenreaders
moved settings state back to original

fixed status updates

updated message for more clarity

renamed variables for clarity

added comment

fixed variables

fixed variable again

decreased delay between feedbacks

updated comment
2020-09-16 15:22:04 -04:00
alangsto
917152df22 Merge pull request #330 from edx/alangsto/remove_survey
Removed SurveyMonkey survey
2020-09-15 16:35:45 -04:00
Alie Langston
961c0feb78 removed SurveyMonkey survey 2020-09-15 16:04:46 -04:00
edX Transifex Bot
7d57d86729 fix(i18n): update translations 2020-09-13 17:04:11 -04:00
alangsto
34c5de1340 Merge pull request #324 from edx/alangsto/add_idv_survey
Added SurveyMonkey Survey to IDV submitted page
2020-09-10 15:42:59 -04:00
Alie Langston
81d604d046 adding second round of survey to IDV summary page 2020-09-10 15:22:21 -04:00
Michael Roytman
e6f7e83cf5 Merge pull request #322 from edx/mroytman/use-better-camera-mobile
use 'environment' facing mode when using the camera in the ID photo c…
2020-09-10 14:45:30 -04:00
Michael Roytman
a970e17070 use 'environment' facing mode when using the camera in the ID photo context; this will open the rear facing camera on mobile 2020-09-10 14:33:42 -04:00
alangsto
f471ae0aa7 Merge pull request #323 from edx/alangsto/add_error_messaging
Added error messaging for image upload and idv submission
2020-09-10 14:29:20 -04:00
Alie Langston
b9efe6faee added error messaging for image upload and idv submission
updated testing

wrapped button click

moved maximum file size to a const variable
2020-09-10 14:23:35 -04:00
edX Transifex Bot
2dbccec1f1 fix(i18n): update translations 2020-09-06 17:03:58 -04:00
Michael Roytman
9f38b975d9 Merge pull request #318 from edx/mroytman/fix-iOS-camera-bug-popout
add playsInline attribute to video element to ensure it does not pop out into full screen video on iOS Safari
2020-09-04 14:54:29 -04:00
Michael Roytman
ae355cefcf add playsInline attribute to video element to ensure it does not pop out into full screen video on iOS Safari 2020-09-04 14:03:29 -04:00
alangsto
d63dfc929f Merge pull request #317 from edx/alangsto/fix_camera_resolution
Added logic for adjusting image resolution
2020-09-03 14:11:13 -04:00
Alie Langston
64be9edeac adjusted size factor based on camera resolution
added additional check so that tests pass

updates for requested changes
2020-09-03 14:05:36 -04:00
alangsto
5f4f82eae1 Merge pull request #315 from edx/alangsto/blazeface_fix
Fix for blazeface/camera issue
2020-09-02 10:43:40 -04:00
Alie Langston
c8c7352549 updated camera and canvas ratio to match, and updated ranges for landmarks 2020-09-02 10:34:23 -04:00
Renovate Bot
88206e4282 fix(deps): update dependency @tensorflow/tfjs-core to v1.6.1 2020-09-01 22:56:01 +00:00
Renovate Bot
d8e23b1a02 fix(deps): update dependency @tensorflow/tfjs-converter to v1.6.1 2020-09-01 21:58:04 +00:00
alangsto
5db21d2483 Merge pull request #302 from edx/alangsto/add_object_tracking
added object tracking
2020-09-01 16:48:39 -04:00
Alie Langston
526d6114f2 added object tracking
moved load of library

updated test

removed async

trying to retest

Retesting

added test back

fixed errors due to next button

removed try catch so errors occur

added try catch back

added in ignore

readded libraries

stops detection when photo is taken, stops erroring issue

added help text

added spinner and better mocked blazeface

moved img element back to correct place

updated for requested changes

updates for requested changes

added timeout for test

updated blazeface to pull from forked repo, and added changes based on accessibility feedback
2020-09-01 15:54:20 -04:00
Renovate Bot
0cc38e2dc6 chore(deps): update dependency enzyme-adapter-react-16 to v1.15.4 2020-08-31 06:47:57 +00:00
edX Transifex Bot
380ca7c816 fix(i18n): update translations 2020-08-30 17:03:45 -04:00
Justin Hynes
bcb20234ab Merge pull request #296 from edx/jhynes/microba-534_demographics-i18n
MICROBA-534 | Remove hardcoded Demographics options, pull choices from Demographics API
2020-08-28 07:43:14 -04:00
Justin Hynes
87ff50ace8 MICROBA-534 | Remove hardcoded Demographics options, pull choices from Demographics API
[MICROBA-534]
- Pull Demographics field choices from Demographics API to support I18N/L10N effors so that we don't need duplicated choices for the fields in two places (backend and frontend)
- Refactor error detection code in DemographicsSection component
2020-08-27 14:31:06 -04:00
alangsto
b409aff6b4 Merge pull request #301 from edx/alangsto/fix_focus_on_permissions_panel
Fixed HTML element focus on camera permissions panel
2020-08-25 12:50:51 -04:00
Michael Roytman
2b67d037bf Merge pull request #293 from edx/mroytman/MST-361-stop-camera-after-submit
stop camera once user successfully submits photos to IDVerification s…
2020-08-25 12:19:16 -04:00
Alie Langston
4137996a91 fixed focus issue
removed test id

removed space
2020-08-25 12:16:17 -04:00
Michael Roytman
1319bd6377 stop camera once user successfully submits photos to IDVerification service 2020-08-25 12:07:58 -04:00
alangsto
a579e86e98 Merge pull request #300 from edx/alangsto/add_image_upload_toggle
Added option for ID image upload
2020-08-24 15:51:18 -04:00
Alie Langston
74bb2fb45f added option for image upload
added friction to allowing users to upload id image

only shows picture within component if it has been uploaded, not if it was captured through the webcam
2020-08-24 15:34:53 -04:00
edX Transifex Bot
11dbbad20b fix(i18n): update translations 2020-08-23 17:03:33 -04:00
alangsto
a0227f1dbc Merge pull request #298 from edx/alangsto/add_instructions_for_camera_permissions
Added instructions for camera permissions
2020-08-20 10:44:17 -04:00
Alie Langston
f6a7a6063c added instructions for enabling camera
updated message

created separate component
2020-08-20 10:39:27 -04:00
Zachary Hancock
0aa02687e6 Merge pull request #297 from edx/zhancock/camera-fix
Limit camera to 720p
2020-08-19 09:02:59 -04:00
Zach Hancock
303f6a5d3f limit camera to 720p 2020-08-18 16:45:48 -04:00
edX Transifex Bot
b262f42c8d fix(i18n): update translations 2020-08-16 17:07:29 -04:00
Bianca Severino
b92794b72c Merge pull request #294 from edx/bseverino/idv-tests
Add tests for new IDV functionality
2020-08-14 12:35:34 -04:00
Bianca Severino
3f754fa114 Add tests for new IDV functionality 2020-08-14 11:39:26 -04:00
Thomas Tracy
bf274e5186 Fix extra call to LMS when PATCHing demographics (#291)
We were making an extra call to the LMS when making a PATCH to the
demographics service which we don't need to do.

The keys for demographics fields we not being removed from the
accountSettings object properly, which made them be seen as changes,
which triggered the call to the LMS. I've added a constant
DEMOGRAPHICS_FIELDS that keys out those fields, removing the call.
2020-08-13 15:00:24 -04:00
Bianca Severino
be9cf70c5c Merge pull request #292 from edx/bseverino/idv-improvements
[MST-353] IDV a11y and UX improvements
2020-08-13 10:01:44 -04:00
Bianca Severino
c00ea15920 IDV a11y and UX improvements 2020-08-12 16:10:30 -04:00
Bianca Severino
5a8bd309e7 Merge pull request #289 from edx/bseverino/idv-improvements
Add i18n and UX improvements to IDV
2020-08-11 11:43:23 -04:00
Bianca Severino
d83ea54272 Add i18n and UX improvements to IDV 2020-08-11 10:19:21 -04:00
Justin Hynes
eae18d9c63 Merge pull request #290 from edx/jhynes/microba-405_add-missing-income-option
MICROBA-405 | Add missing demographics income option
2020-08-11 08:48:23 -04:00
Justin Hynes
a18da61cec MICROBA-405 | Add missing demographics income option
[MICROBA-405]
- Add missing demographics income option
2020-08-10 15:18:43 -04:00
Renovate Bot
80d5fd2a34 chore(deps): update dependency enzyme-adapter-react-16 to v1.15.3 2020-08-08 03:41:58 +00:00
Jeff LaJoie
d4e9ba0420 Merge pull request #287 from edx/jlajoie/ENT-3302
fix: ENTERPRISE_LEARNER_PORTAL_HOSTNAME added for header updates
2020-08-06 07:25:56 -04:00
Jeff LaJoie
5add376c31 fix: ENTERPRISE_LEARNER_PORTAL_HOSTNAME added for header updates 2020-08-06 07:13:04 -04:00
Bianca Severino
559c9aa1a9 Merge pull request #284 from edx/bseverino/idv-testing
Add React Testing Library and write unit tests for IDV
2020-08-03 09:13:08 -04:00
Bianca Severino
ea8a6d29d0 Add React Testing Library and write unit tests for IDV 2020-08-03 09:05:44 -04:00
edX Transifex Bot
4689482137 fix(i18n): update translations 2020-08-02 17:06:56 -04:00
Renovate Bot
d48be79e53 Update dependency @edx/paragon to v9.1.1 2020-07-31 19:14:09 +00:00
Thomas Tracy
ed94cc68e3 Force content type header to 'application/json' (#282) 2020-07-29 15:06:36 -04:00
Bianca Severino
05740b37ff Merge pull request #281 from edx/bseverino/remove-survey
Remove SurveyMonkey widget from IDV
2020-07-29 11:07:55 -04:00
Bianca Severino
b2c8164cd1 Remove SurveyMonkey widget from IDV 2020-07-29 09:58:17 -04:00
Bianca Severino
47b369f797 Merge pull request #280 from edx/bseverino/idv-spinner
Update Paragon and add loading state + spinner to IDV summary page
2020-07-24 16:16:58 -04:00
Bianca Severino
88239f2700 Update Paragon and add loading state + spinner to IDV summary page 2020-07-24 16:07:53 -04:00
Bianca Severino
602c4b484c Merge pull request #279 from edx/bseverino/message-fix
Fixed IDV submitted message to estimate 5 days rather than 1-2 days
2020-07-24 12:16:45 -04:00
Bianca Severino
45ec573ff9 Fixed IDV cubmitted message to estimate 5 days rather than 1-2 days 2020-07-24 11:39:56 -04:00
Justin Hynes
e0befb8b60 Merge pull request #272 from edx/jhynes/microba-488_improve-error-handling
MICROBA-488 | Improve error handling in DemographicsSection
2020-07-24 08:58:43 -04:00
Justin Hynes
9f4d944670 MICROBA-488 | Improve error handling in DemographicsSection
[MICROBA-488]
- Improve error handling in DemographicsSection
2020-07-24 08:49:12 -04:00
Bianca Severino
60d26649dd Merge pull request #278 from edx/bseverino/tracking-fix
Changed sendTrackingLogEvent to sendTrackEvent
2020-07-23 19:21:04 -04:00
Bianca Severino
55f25c73ca Changed sendTrackingLogEvent to sendTrackEvent 2020-07-23 19:03:11 -04:00
Renovate Bot
29dbdf6ad0 Update dependency react-scrollspy to v3.4.3 2020-07-23 02:05:03 +00:00
Bianca Severino
5bfa834563 Merge pull request #276 from edx/bseverino/css-fix
Remove PurgeCSS
2020-07-22 16:04:52 -04:00
Bianca Severino
5e3af50e3b Remove PurgeCSS 2020-07-22 12:51:24 -04:00
Bianca Severino
e30a20b185 Merge pull request #273 from edx/bseverino/idv-analytics
Add tracking log events to IDV
2020-07-22 11:14:46 -04:00
Renovate Bot
2743e05890 Update dependency codecov to v3.7.2 2020-07-22 03:26:31 +00:00
Olivia Ruiz-Knott
71c0563e3a MICROBA-487 Fix legal text spacing on demographics section (#274) 2020-07-21 15:58:55 -04:00
Bianca Severino
5d8d327a48 Added tracking log events to IDV 2020-07-21 13:57:09 -04:00
Albert (AJ) St. Aubin
15ba7c087e Merge pull request #266 from edx/aj/MICROBA-484
[MICROBA-484] Scroll to section based on URL
2020-07-21 13:45:27 -04:00
Olivia Ruiz-Knott
6df7ad243b MICROBA-463 Collapse phone number field when coaching toggled on (#271) 2020-07-21 13:09:45 -04:00
Bianca Severino
594d3ff9f1 Merge pull request #264 from edx/bseverino/hide-image-upload
ID Verification: Hide image upload component
2020-07-21 10:16:09 -04:00
Renovate Bot
7bb9d09dae fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.30 2020-07-21 13:48:44 +00:00
dependabot[bot]
0e6233b693 chore(deps-dev): bump codecov from 3.6.5 to 3.7.1 (#269)
Bumps [codecov](https://github.com/codecov/codecov-node) from 3.6.5 to 3.7.1.
- [Release notes](https://github.com/codecov/codecov-node/releases)
- [Commits](https://github.com/codecov/codecov-node/compare/v3.6.5...v3.7.1)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-07-21 09:24:35 -04:00
Renovate Bot
22087d2d2c fix(deps): update dependency @edx/frontend-component-footer to v10.0.11 2020-07-20 19:58:22 +00:00
Albert (AJ) St. Aubin
64798cdc80 Remove jumpy scrolling 2020-07-20 15:12:51 -04:00
Albert (AJ) St. Aubin
f53ba967e5 Simplification 2020-07-20 14:28:23 -04:00
Albert (AJ) St. Aubin
2deb47d542 [MICROBA-484] Scroll to section based on URL
Added in a check for a locationHash and loaded state to
scroll to the section in the URL so that the page would load to a Nav
Section after loading data.
2020-07-20 14:02:48 -04:00
edX Transifex Bot
1b9dd3bdf5 fix(i18n): update translations 2020-07-19 17:06:34 -04:00
Bianca Severino
fd3c8ede6d Hide image upload flow 2020-07-17 14:11:58 -04:00
Justin Hynes
c1fdace72d Merge pull request #261 from edx/jhynes/microba-474_determine-demographics-visibility
MICROBA-474 | Gate Demographics questions and link via LMS API call
2020-07-16 08:27:03 -04:00
Justin Hynes
4ca4d55796 MICROBA-474 | Gate Demographics questions and link via LMS API call
[MICROBA-474]
- Add logic to show (or hide) the "Optional Information" (Demographics questions) section on the Account settings page via call to new LMS API
2020-07-15 12:01:52 -04:00
Bianca Severino
2da606bf6f Merge pull request #260 from edx/bseverino/idv-course-key
Persist courseRunKey in ID verification flow
2020-07-13 16:45:19 -04:00
Bianca Severino
997a3c0b98 Add ability to store and retrieve course run key
Add course run key to POST request
Change text based on return location
Minor style fixes
2020-07-13 14:41:35 -04:00
Justin Hynes
f627257a1c Merge pull request #258 from edx/jhynes/microba-471_add-self-employed-choice
MICROBA-471 | Add 'self-employed' option to work status options
2020-07-13 08:32:31 -04:00
edX Transifex Bot
9aa2a816b4 fix(i18n): update translations 2020-07-12 17:06:13 -04:00
Justin Hynes
fc54dd528f MICROBA-471 | Add 'self-employed' option to work status options
Add support for self-employed option added to the work status options on the UserDemographics model.
2020-07-10 15:50:06 -04:00
Albert (AJ) St. Aubin
53fc1b325c Merge pull request #257 from edx/aj/MICROBA-445
Adding a link to the legal copy for demographics
2020-07-09 13:06:22 -04:00
Albert (AJ) St. Aubin
48b02cd2de Adding a link to the legal copy for demographics 2020-07-09 12:45:12 -04:00
edX Transifex Bot
490274b2ed fix(i18n): update translations 2020-07-05 17:06:23 -04:00
Renovate Bot
10dc9aabde Update dependency @edx/frontend-component-footer to v10.0.10 2020-07-04 11:16:45 +00:00
Bianca Severino
74ec75781e Merge pull request #253 from edx/bseverino/surveymonkey
Add SurveyMonkey widget to IDV submitted page
2020-07-02 10:52:47 -04:00
Justin Hynes
c428d3044f Merge pull request #254 from edx/jhynes/microba-465_fix-demographics-alert-behavior
MICROBA-465 | Fix Demographics alert behavior
2020-07-01 12:46:19 -04:00
Justin Hynes
d9777fe48e MICROBA-465 | Fix alert behavior on Accounts to only display on Demographics errors
[MICROBA-465]
- Fix Demographics alert behavior on Accounts page to only display on Demographics errors
2020-07-01 12:07:28 -04:00
Bianca Severino
6b7ab05dd5 Add SurveyMonkey widget to IDV submitted page 2020-07-01 11:07:59 -04:00
Justin Hynes
ce79cd7f5a Merge pull request #251 from edx/jhynes/microba-309_demographics
MICROBA-309 | DemographicsSection component tests and error handling
2020-07-01 08:29:26 -04:00
Justin Hynes
b8ab0a2150 MICROBA-309 | DemographicsSection component tests and error handling
[MICROBA-309]
- Fix defect in ethnicityFieldDisplay function. Check to make sure key/field exists before accessing
- Add additional error handling. Add ability to show an Alert to the user if a call to the Demographics API fails
- Add tests
2020-07-01 08:11:47 -04:00
edX Transifex Bot
56569c717c fix(i18n): update translations 2020-06-28 17:06:01 -04:00
Albert (AJ) St. Aubin
45511860c2 Merge pull request #249 from edx/aj/MICROBA-368_coaching_text
[MICROBA-368] Updated text for the Coaching consent to align with
2020-06-26 15:00:14 -04:00
Albert (AJ) St. Aubin
11f7d56e75 [MICROBA-368] Updated text for the Coaching consent to align with
messaging timeline
2020-06-26 14:28:09 -04:00
Thomas Tracy
f19380ebac Hide demographics sidenav link when demographics is off (#248) 2020-06-25 12:56:34 -04:00
Thomas Tracy
5eb43871c7 MB-17: Refactor coaching_consent form (#244)
We were having issues with how the coaching consent form was sending
data. Previously, we were hitting 2 endpoints - one from the coaching
plugin, and one from the LMS. This changes fixes a few issues:

* CoachingConsent now hits one api that handles saving the phone_number,
full_name, and coaching_consent.
* This component does not need to share any of this data, so it is not
connected to Redux. Everything to do with patching is done in the
CoachingConsent component.
* Fetching data is still done through actions provided by redux.
* This change does not effect the fields in the root account settings
page

* Add tests for coaching consent form
2020-06-24 10:37:58 -04:00
Bianca Severino
a2388bffc2 Merge pull request #245 from edx/bseverino/idv-ui
IDV UI improvements
2020-06-22 16:46:59 -04:00
Bianca Severino
ebe6af0913 Implemented UI improvements
Fixed padding on 'next' button and collapsed camera help text

Added functionality to submit button

Styling edit to differentiate cards

Removed todo comments
2020-06-22 14:11:20 -04:00
edX Transifex Bot
fd6ba7847a fix(i18n): update translations 2020-06-21 17:05:29 -04:00
Renovate Bot
f051905da1 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.29 2020-06-18 21:45:19 +00:00
Olivia Ruiz-Knott
5eddb35e0b Merge pull request #242 from edx/ork/MICROBA-309_collect-demographics-on-account-page
MICROBA-309 Collect demographics on account page
2020-06-18 17:17:12 -04:00
Olivia Ruiz-Knott
0196245c13 MICROBA-309 Collect demographics on account page
Add new fields to account page for demographics collection

MICROBA-309
2020-06-18 17:07:54 -04:00
Renovate Bot
0c53a29094 fix(deps): update dependency formdata-polyfill to v3.0.20 2020-06-17 21:13:19 +00:00
Renovate Bot
60643f6215 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.11 2020-06-16 23:14:18 +00:00
Renovate Bot
b0aa91fc98 fix(deps): update dependency qs to v6.9.4 2020-06-16 20:21:47 +00:00
Bianca Severino
09813fa689 Merge pull request #210 from edx/hack/idv
Hackathon XXIV -- Add ID verification page
2020-06-16 15:57:16 -04:00
Bianca Severino
b05a40d0aa Code cleanup and i18n compatibility
Replaced hardcoded text with i18n components

Added title to confirmation button for a/b testing purposes

Redirect the user to the beginning of the process on reload to avoid losing camera access, also removed unused code

Update src/id-verification/IdVerificationContext.jsx, make children prop required

Co-authored-by: Kyle McCormick <kmccormick@edx.org>

Remove todo comment in ImageFileUpload.jsx

Added comment to service.js and removed unused code from index.js
2020-06-16 15:46:19 -04:00
Kyle McCormick
1a76468587 Add ID verification page
Fix 404 page from displaying when it shouldn't

Was displaying below the settings page itself

Add panels for each step of the verification flow

Add IdVerificationContext

Add BasePanel

Revert "Add BasePanel"

This reverts commit 2ddca14b4b8b35cd23f0525f49070753c09d361d.

Add BasePanel

Focus heading elements on mount

Add current account name to GetNameIdPanel

Fix nested button focus problem

Add a friendly camera permission request

Remove double ctas

lint

Add initial service.js file; not tested

Add image file uploads

fix temp heading of photo context panel

Add photo id name input

Add redirect logic for users who find themselves too far in the flow

add wip SubmittedPanel

Wire up Submit button

Update routing-utilities.js

Preview image on summarypenl; still buggy

Add some content and styles

update first panel content

Update headings

Use ImagePreview component on SummaryPanel

Fix service.js; it works now

More content and fix for name input

ImagePreview: Change 'name' to 'alt'

Add content to camera request

Fix ImagePreview alt text

Iterating on SummaryPage

Add privacy info modal

Update name entry

Make the footer sticky to the bottom

Update upload photo screens

Add photo to name panel

camera component

overlay camera button

implement camera component

remove camera tracking

Add retake photo buttons

Add service for verification status endpoint

delete old css

lint fix

Better name edit state

portrait photo page

Add edit name from summary

Add content to submitted screen

lint

Rename status service function

Add clarity to summary view text

First pass at gating based on existing idv

Clean up IDv-status-gating screens

finish up webcam/photo pages

remove unused tracking library
2020-06-10 15:02:27 -04:00
Renovate Bot
93ef8d2b04 fix(deps): update dependency @fortawesome/react-fontawesome to v0.1.10 2020-06-02 18:19:14 +00:00
edX Transifex Bot
98efe2649a fix(i18n): update translations 2020-05-31 17:04:50 -04:00
AsadAzam
80cc8f156f Merge pull request #234 from edx/asad/prod-1542
Added invalid password check on delete
2020-05-29 17:27:33 +05:00
Asad
1364ca5711 Added invalid password check on delete 2020-05-29 14:28:20 +05:00
edX Transifex Bot
c337e03a4d fix(i18n): update translations 2020-05-24 17:04:37 -04:00
Thomas Tracy
c6aedbea29 MB-366 don't save non eligible coaching number (#230)
* MB-366 don't save non eligible coaching number

This fixes a few little bugs in the system as well as the one listed in
the ticket.

* We no longer make an extra API call to user profile when toggling
coaching
* The user now must toggle coaching off to save a non-eligible phone
number
* If the user changes to another US based phone number, or is not
consented to coaching, the phone number will save as normal.

* Fix weird code
2020-05-19 10:35:21 -04:00
edX Transifex Bot
6e099457db fix(i18n): update translations 2020-05-17 17:04:24 -04:00
Olivia Ruiz-Knott
54fa5b970a Merge pull request #220 from edx/ork/MICROBA-311_add-state-to-account-settings
MICROBA-311 Add state field to account settings
2020-05-13 10:12:49 -04:00
Olivia Ruiz-Knott
38371d1a46 MICROBA-311 Add state field to account settings
Add state field dropdown to account settings page if country is US;
add states list; handle localization
2020-05-12 14:56:43 -04:00
edX Transifex Bot
f805480e21 fix(i18n): update translations 2020-05-11 02:01:25 -04:00
Matt Tuchfarber
cc3bf06a6f Merge pull request #227 from edx/tuchfarber/fix_coaching_form_for_ents
Only save coaching name if not managed profile
2020-05-08 14:33:42 -04:00
Matt Tuchfarber
0e8d7622c6 Only save coaching name if not managed profile
Managed profiles don't allow the user to change their name so we disable
the name input and skip name submission during coaching signup.
2020-05-08 14:28:21 -04:00
Waheed Ahmed
9faf28203b Merge pull request #226 from edx/waheed/PROD-1427-handle-403-password-reset-response
Handle 403 password reset response.
2020-05-07 23:35:10 +05:00
Waheed Ahmed
e94d05d9e2 Handle 403 password reset response.
Recently we have changed rate limiting configuration for password reset
endpoint to one request per email and IP. I have added the frontend
functionality to show proper error message to users.

PROD-1427
2020-05-07 22:39:36 +05:00
Thomas Tracy
8b4f7818fc Fix coaching toggle when phone number not saved (#225) 2020-05-06 14:57:07 -04:00
Thomas Tracy
f18c71d13a Prevent phone_number save on coaching save failure (#222)
Originally, we had it set up so that the coaching consent form saved 33
values in parallel. This add a new saga (saveMultipleSettings) to save
those 3 values in sync. This way, if one fails, the rest of the calls
fail.

Something to watch out for here: the order matters. The array that you
give the save function must be ordered in the way you need the data
saved.
2020-05-06 10:43:19 -04:00
Matt Tuchfarber
a1aeb7035e Merge pull request #224 from edx/tuchfarber/fix_coaching_api_call
Fix coaching data API call
2020-05-05 16:40:50 -04:00
Matt Tuchfarber
ef85448e27 Fix coaching data API call
Was mixing extraction of data key in call
2020-05-05 16:10:30 -04:00
Matt Tuchfarber
741cfb6aac Merge pull request #221 from edx/tuchfarber/catch_403_for_inactive_user
Catch 403 for inactive user on coaching API
2020-05-05 11:35:00 -04:00
Matt Tuchfarber
c808c8c0a1 Catch 403 for inactive user on coaching API 2020-05-05 11:14:05 -04:00
Matt Tuchfarber
4eb96e64c7 Merge pull request #218 from edx/tuchfarber/reset_coaching_submission_states
Reset coaching submission state on submit
2020-05-04 11:00:19 -04:00
Albert (AJ) St. Aubin
f1ec989054 Merge pull request #219 from edx/aj/coaching_text_change
Update to the text used by the Coarching account settings
2020-05-04 08:44:18 -04:00
Albert (AJ) St. Aubin
54032e6ec5 Update to the text used by the Coarching account settings 2020-05-01 15:45:44 -04:00
Matt Tuchfarber
ef5c303fbc Reset coaching submission state on submit 2020-05-01 14:51:51 -04:00
Thomas Tracy
caa06a08b0 MICROBA-hotfix: coaching sign-up save phone number (#217)
Before this fix, we were having an issues where upon the first time a
user signs up with the coaching form, the phone number would not save.
This was because of the way we patch the user in the form. The phone
number was saving properly, then getting overwritten with `null`.

This fixes that issue, and also cleans up an error message.
2020-05-01 14:14:50 -04:00
Mike OConnell
5e4278ea5a Merge pull request #216 from edx/sameen/fix-account-settings-bug
Handled no enterprise returned case
2020-05-01 11:43:23 -04:00
sameenfatima78
96dc3f7e3f fixed-account-setting-bug 2020-05-01 20:19:46 +05:00
Matt Tuchfarber
5726be2805 Merge pull request #213 from edx/tuchfarber/default_consent_false
Default eligble for coaching to false
2020-04-27 17:07:58 -04:00
Matt Tuchfarber
73c66d5d18 Default eligble for coaching to false
A user without coaching data should no longer see the toggle
2020-04-27 16:43:58 -04:00
Sameen Fatima
19ef66cf42 Merge pull request #211 from edx/sameen/ENT-2741
ENT-2741: Ent name on account settings should be of current ent
2020-04-24 12:57:24 +05:00
sameenfatima78
c83d76e1a9 ENT-2741-Fixed-Enterprise-Name-Banner-scenario
incorporated-feedback

fixed-lint-issue

incorporated-additional-feedback
2020-04-23 18:05:36 +05:00
David Joy
2f2abd54ff Fixing logout URLs for dev and test. (#212) 2020-04-21 12:56:16 -04:00
Zaman Afzal
0b00fa21a2 ENT-2651 Recovery email Field UX logic on Account Settings page was not same to Dashboard (#206)
* ENT-2651 Recovery email Field UX logic on Account Settings page was not same to Dashboard
2020-04-08 15:36:40 +05:00
edX Transifex Bot
1aa6a22c1c fix(i18n): update translations 2020-04-05 17:06:49 -04:00
Matt Tuchfarber
d3d0bf97b6 Merge pull request #205 from edx/aj/MICROBA-234_MB_phone
Add coaching consent form
2020-04-02 14:22:48 -04:00
Albert (AJ) St. Aubin
7361674019 Created the form to collect phone number and name for coaching. 2020-04-02 13:53:36 -04:00
Renovate Bot
10a3f1fb35 fix(deps): update dependency @edx/frontend-component-footer to v10.0.9 2020-03-23 17:44:10 +00:00
Renovate Bot
06d018fc62 fix(deps): update dependency @fortawesome/fontawesome-svg-core to v1.2.28 2020-03-23 16:19:03 +00:00
edX Transifex Bot
3e5bf2b19a fix(i18n): update translations 2020-03-22 17:06:24 -04:00
adeel khan
724a7f9201 Merge pull request #198 from edx/adeel/prod_1349_fix_timezone_dropdown
Fix timezone dropdown under Site Preferences
2020-03-20 00:59:10 +05:00
Adeel Khan
66b27a01d0 Fix timezone dropdown under Site Preferences
This patch would fix timezone dropdown which
doesn't change  value on selection.

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

MB-196

Adds phone number and coaching consent toggle to the profile. Currently,
toggled off in production, until we are ready for MB learners to start
recieving coaching.

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

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

* Add phone number field and coaching consent toggle

MB-196

Adds phone number and coaching consent toggle to the profile. Currently,
toggled off in production, until we are ready for MB learners to start
recieving coaching.

* Made requested changes and additional fixes

* Requested changes

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

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

* Enabling duplicate_provider check again

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

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

* Initializing the app via App.initialize

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

* Convert delete-account service to use App.

* Using App for services and cleaning up associated code.

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

* Cleaning up “common” and some dead code.

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

* Moving files into data subdirectory in account-settings

Including all the utils stuff.

* Moving top level reducers/sagas into a data dir

* Fix import bug with sagaUtils

* Removing connected-react-router

* Ceasing to use authentication and configuration from redux

Also removing some unnecessary test config.

* Updating redux init to default to prod.

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

* Moving the duplicateTpaProvider logic out of redux

This lets us stop setting initial state on redux.

Also removing url-polyfill.

* A little cleanup.

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

* Running npm audit fix

* Removing CSRF_COOKIE_NAME
2019-10-11 15:03:29 -04:00
albemarle
d124a91688 chore: use AGPLv3 instead of GPLv3 (#113) 2019-08-21 14:56:14 -04:00
edX Transifex Bot
5c578af96c fix(i18n): update translations 2019-08-13 14:18:12 -04:00
Robert Raposa
3076227249 Merge pull request #111 from edx/abutterworth/upgrade-auth
fix: upgrade frontend-auth
2019-07-30 17:07:35 -04:00
Adam Butterworth
5cb1947a69 fix: upgrade frontend-auth 2019-07-30 14:54:00 -04:00
edX Transifex Bot
39cd052a81 fix(i18n): update translations 2019-07-21 17:02:20 -04:00
166 changed files with 33745 additions and 14769 deletions

View File

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

19
.env Normal file
View File

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

23
.env.development Normal file
View File

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

20
.env.test Normal file
View File

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

View File

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

View File

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

3
.eslintrc.js Executable file
View File

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

View File

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

View File

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

149
LICENSE
View File

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

View File

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

View File

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

7
jest.config.js Normal file
View File

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

View File

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

34082
package-lock.json generated

File diff suppressed because it is too large Load Diff

212
package.json Executable file → Normal file
View File

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

View File

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

9
renovate.json Normal file
View File

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

View File

@@ -1,21 +1,25 @@
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig, history, getQueryParameters } from '@edx/frontend-platform';
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import memoize from 'memoize-one';
import findIndex from 'lodash.findindex';
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import {
injectIntl,
intlShape,
FormattedMessage,
} from '@edx/frontend-i18n';
getCountryList,
getLanguageList,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import messages from './AccountSettingsPage.messages';
import { fetchSettings, saveSettings, updateDraft } from './actions';
import { accountSettingsPageSelector } from './selectors';
import { Alert, PageLoading } from '../common';
import { fetchSettings, saveSettings, updateDraft } from './data/actions';
import { accountSettingsPageSelector } from './data/selectors';
import PageLoading from './PageLoading';
import Alert from './Alert';
import JumpNav from './JumpNav';
import DeleteAccount from './delete-account';
import EditableField from './EditableField';
@@ -27,40 +31,67 @@ import {
YEAR_OF_BIRTH_OPTIONS,
EDUCATION_LEVELS,
GENDER_OPTIONS,
} from './constants';
COUNTRY_WITH_STATES,
getStatesList,
} from './data/constants';
import { fetchSiteLanguages } from './site-language';
import CoachingToggle from './coaching/CoachingToggle';
import DemographicsSection from './demographics/DemographicsSection';
class AccountSettingsPage extends React.Component {
constructor(props) {
super(props);
this.educationLevels = EDUCATION_LEVELS.map(key => ({
value: key,
label: props.intl.formatMessage(messages[`account.settings.field.education.levels.${key || 'empty'}`]),
}));
this.genderOptions = GENDER_OPTIONS.map(key => ({
value: key,
label: props.intl.formatMessage(messages[`account.settings.field.gender.options.${key || 'empty'}`]),
}));
this.languageProficiencyOptions = [{
value: '',
label: props.intl.formatMessage(messages['account.settings.field.language_proficiencies.options.empty']),
}].concat(props.languageProficiencyOptions);
this.yearOfBirthOptions = [{
value: '',
label: props.intl.formatMessage(messages['account.settings.field.year_of_birth.options.empty']),
}].concat(YEAR_OF_BIRTH_OPTIONS);
this.countryOptions = [{
value: '',
label: props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(props.countryOptions);
constructor(props, context) {
super(props, context);
// If there is a "duplicate_provider" query parameter, that's the backend's
// way of telling us that the provider account the user tried to link is already linked
// to another Open edX account. We use this to display a message to that effect, and remove the
// parameter from the URL.
const duplicateTpaProvider = getQueryParameters().duplicate_provider;
if (duplicateTpaProvider !== undefined) {
history.replace(history.location.pathname);
}
this.state = {
duplicateTpaProvider,
};
this.navLinkRefs = {
'#basic-information': React.createRef(),
'#profile-information': React.createRef(),
'#demographics-information': React.createRef(),
'#social-media': React.createRef(),
'#site-preferences': React.createRef(),
'#linked-accounts': React.createRef(),
'#delete-account': React.createRef(),
};
}
componentDidMount() {
this.props.fetchSettings();
this.props.fetchSiteLanguages();
sendTrackingLogEvent('edx.user.settings.viewed', {
page: 'account',
visibility: null,
user_id: this.context.authenticatedUser.userId,
});
}
getTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions) => {
componentDidUpdate(prevProps) {
if (prevProps.loading && !prevProps.loaded && this.props.loaded) {
const locationHash = global.location.hash;
// Check for the locationHash in the URL and then scroll to it if it is in the
// NavLinks list
if (typeof locationHash !== 'string')
return;
if (Object.keys(this.navLinkRefs).includes(locationHash) && this.navLinkRefs[locationHash].current) {
window.scrollTo(0, this.navLinkRefs[locationHash].current.offsetTop)
}
}
}
// NOTE: We need 'locale' for the memoization in getLocalizedTimeZoneOptions. Don't remove it!
// eslint-disable-next-line no-unused-vars
getLocalizedTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions, locale) => {
const concatTimeZoneOptions = [{
label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.default']),
value: '',
@@ -78,6 +109,33 @@ class AccountSettingsPage extends React.Component {
return concatTimeZoneOptions;
});
getLocalizedOptions = memoize((locale, country) => ({
countryOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']),
}].concat(getCountryList(locale).map(({ code, name }) => ({ value: code, label: name }))),
stateOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']),
}].concat(getStatesList(country)),
languageProficiencyOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.language_proficiencies.options.empty']),
}].concat(getLanguageList(locale).map(({ code, name }) => ({ value: code, label: name }))),
yearOfBirthOptions: [{
value: '',
label: this.props.intl.formatMessage(messages['account.settings.field.year_of_birth.options.empty']),
}].concat(YEAR_OF_BIRTH_OPTIONS),
educationLevelOptions: EDUCATION_LEVELS.map(key => ({
value: key,
label: this.props.intl.formatMessage(messages[`account.settings.field.education.levels.${key || 'empty'}`]),
})),
genderOptions: GENDER_OPTIONS.map(key => ({
value: key,
label: this.props.intl.formatMessage(messages[`account.settings.field.gender.options.${key || 'empty'}`]),
})),
}));
isEditable(fieldName) {
return !this.props.staticFields.includes(fieldName);
}
@@ -97,7 +155,7 @@ class AccountSettingsPage extends React.Component {
};
renderDuplicateTpaProviderMessage() {
if (!this.props.duplicateTpaProvider) {
if (!this.state.duplicateTpaProvider) {
return null;
}
@@ -109,7 +167,7 @@ class AccountSettingsPage extends React.Component {
defaultMessage="The {provider} account you selected is already linked to another edX account."
description="alert message informing the user that the third-party account they attempted to link is already linked to another edX account"
values={{
provider: <b>{this.props.duplicateTpaProvider}</b>,
provider: <b>{this.state.duplicateTpaProvider}</b>,
}}
/>
</Alert>
@@ -132,7 +190,7 @@ class AccountSettingsPage extends React.Component {
values={{
managerTitle: <b>{this.props.profileDataManager}</b>,
support: (
<Hyperlink destination={this.props.supportUrl} target="_blank">
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
<FormattedMessage
id="account.settings.message.managed.settings.support"
defaultMessage="support"
@@ -157,7 +215,7 @@ class AccountSettingsPage extends React.Component {
}
renderSecondaryEmailField(editableFieldProps) {
if (this.props.hiddenFields.includes('secondary_email')) {
if (!Boolean(this.props.formValues.secondary_email_enabled)) {
return null;
}
@@ -173,22 +231,47 @@ 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']}/>
);
} else {
return null;
}
}
renderContent() {
const editableFieldProps = {
onChange: this.handleEditableFieldChange,
onSubmit: this.handleSubmit,
};
const timeZoneOptions = this.getTimeZoneOptions(
// Memoized options lists
const {
countryOptions,
stateOptions,
languageProficiencyOptions,
yearOfBirthOptions,
educationLevelOptions,
genderOptions,
} = this.getLocalizedOptions(this.context.locale, this.props.formValues.country);
// Show State field only if the country is US (could include Canada later)
const showState = this.props.formValues.country == COUNTRY_WITH_STATES;
const timeZoneOptions = this.getLocalizedTimeZoneOptions(
this.props.timeZoneOptions,
this.props.countryTimeZoneOptions,
this.context.locale,
);
const hasLinkedTPA = findIndex(this.props.tpaProviders, provider => provider.connected) >= 0;
return (
<React.Fragment>
<div className="account-section" id="basic-information">
<div className="account-section" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
<h2 className="section-heading">
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
</h2>
@@ -240,14 +323,14 @@ class AccountSettingsPage extends React.Component {
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={this.props.formValues.year_of_birth}
options={this.yearOfBirthOptions}
options={yearOfBirthOptions}
{...editableFieldProps}
/>
<EditableField
name="country"
type="select"
value={this.props.formValues.country}
options={this.countryOptions}
options={countryOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
emptyLabel={
this.isEditable('country') ?
@@ -257,9 +340,25 @@ class AccountSettingsPage extends React.Component {
isEditable={this.isEditable('country')}
{...editableFieldProps}
/>
{showState &&
<EditableField
name="state"
type="select"
value={this.props.formValues.state}
options={stateOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.state'])}
emptyLabel={
this.isEditable('state') ?
this.props.intl.formatMessage(messages['account.settings.field.state.empty']) :
this.renderEmptyStaticFieldMessage()
}
isEditable={this.isEditable('state')}
{...editableFieldProps}
/>
}
</div>
<div className="account-section" id="profile-information">
<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>
@@ -268,7 +367,7 @@ class AccountSettingsPage extends React.Component {
name="level_of_education"
type="select"
value={this.props.formValues.level_of_education}
options={this.educationLevels}
options={educationLevelOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.education.empty'])}
{...editableFieldProps}
@@ -277,7 +376,7 @@ class AccountSettingsPage extends React.Component {
name="gender"
type="select"
value={this.props.formValues.gender}
options={this.genderOptions}
options={genderOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.gender.empty'])}
{...editableFieldProps}
@@ -286,13 +385,21 @@ class AccountSettingsPage extends React.Component {
name="language_proficiencies"
type="select"
value={this.props.formValues.language_proficiencies}
options={this.languageProficiencyOptions}
options={languageProficiencyOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
{...editableFieldProps}
/>
{getConfig().COACHING_ENABLED &&
this.props.formValues.coaching.eligible_for_coaching &&
<CoachingToggle
name="coaching"
phone_number={this.props.formValues.phone_number}
coaching={this.props.formValues.coaching}
/>
}
</div>
{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'])}
@@ -325,7 +432,7 @@ class AccountSettingsPage extends React.Component {
/>
</div>
<div className="account-section" id="site-preferences">
<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>
@@ -335,7 +442,7 @@ class AccountSettingsPage extends React.Component {
name="siteLanguage"
type="select"
options={this.props.siteLanguageOptions}
value={this.props.siteLanguage.draftOrSavedValue}
value={this.props.siteLanguage.draft !== undefined ? this.props.siteLanguage.draft : this.context.locale}
label={this.props.intl.formatMessage(messages['account.settings.field.site.language'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.site.language.help.text'])}
{...editableFieldProps}
@@ -343,7 +450,7 @@ class AccountSettingsPage extends React.Component {
<EditableField
name="time_zone"
type="select"
value={this.props.formValues.time_zone || ''}
value={this.props.formValues.time_zone}
options={timeZoneOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
@@ -356,17 +463,16 @@ class AccountSettingsPage extends React.Component {
/>
</div>
<div className="account-section" id="linked-accounts">
<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'])}</p>
<ThirdPartyAuth />
</div>
<div className="account-section" id="delete-account">
<div className="account-section" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
<DeleteAccount
isVerifiedAccount={this.props.isActive}
hasLinkedTPA={hasLinkedTPA}
logoutUrl={this.props.logoutUrl}
/>
</div>
@@ -406,7 +512,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}
@@ -420,6 +528,8 @@ class AccountSettingsPage extends React.Component {
}
}
AccountSettingsPage.contextType = AppContext;
AccountSettingsPage.propTypes = {
intl: intlShape.isRequired,
loading: PropTypes.bool,
@@ -437,32 +547,29 @@ AccountSettingsPage.propTypes = {
level_of_education: PropTypes.string,
gender: PropTypes.string,
language_proficiencies: PropTypes.string,
phone_number: PropTypes.string,
social_link_linkedin: PropTypes.string,
social_link_facebook: PropTypes.string,
social_link_twitter: PropTypes.string,
time_zone: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
}),
}).isRequired,
siteLanguage: PropTypes.shape({
previousValue: PropTypes.string,
draftOrSavedValue: PropTypes.string,
savedValue: PropTypes.string,
draft: PropTypes.string,
}),
siteLanguageOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
countryOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
languageProficiencyOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
})),
profileDataManager: PropTypes.string,
staticFields: PropTypes.arrayOf(PropTypes.string),
hiddenFields: PropTypes.arrayOf(PropTypes.string),
isActive: PropTypes.bool,
secondary_email_enabled: PropTypes.bool,
timeZoneOptions: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
@@ -476,10 +583,7 @@ AccountSettingsPage.propTypes = {
updateDraft: PropTypes.func.isRequired,
saveSettings: PropTypes.func.isRequired,
fetchSettings: PropTypes.func.isRequired,
duplicateTpaProvider: PropTypes.string,
tpaProviders: PropTypes.arrayOf(PropTypes.object),
supportUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
};
AccountSettingsPage.defaultProps = {
@@ -488,16 +592,13 @@ AccountSettingsPage.defaultProps = {
loadingError: null,
siteLanguage: null,
siteLanguageOptions: [],
countryOptions: [],
timeZoneOptions: [],
countryTimeZoneOptions: [],
languageProficiencyOptions: [],
profileDataManager: null,
staticFields: [],
hiddenFields: ['secondary_email'],
duplicateTpaProvider: null,
tpaProviders: [],
isActive: true,
secondary_email_enabled: false,
};
export default connect(accountSettingsPageSelector, {

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-i18n';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.page.heading': {
@@ -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',
@@ -156,6 +161,21 @@ const messages = defineMessages({
defaultMessage: 'Select a Country',
description: 'Option for empty value on account settings country field.',
},
'account.settings.field.state': {
id: 'account.settings.field.state',
defaultMessage: 'State',
description: 'Label for account settings state field.',
},
'account.settings.field.state.empty': {
id: 'account.settings.field.state.empty',
defaultMessage: 'Add state',
description: 'Placeholder for empty account settings state field.',
},
'account.settings.field.state.options.empty': {
id: 'account.settings.field.state.options.empty',
defaultMessage: 'Select a State',
description: 'Option for empty value on account settings state field.',
},
'account.settings.field.site.language': {
id: 'account.settings.field.site.language',
defaultMessage: 'Site language',
@@ -272,6 +292,7 @@ const messages = defineMessages({
defaultMessage: 'Select a Language',
description: 'Option for an empty value on account settings spoken languages field.',
},
'account.settings.field.time.zone': {
id: 'account.settings.field.time.zone',
defaultMessage: 'Time zone',

View File

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

View File

@@ -1,19 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Input, StatefulButton, ValidationFormGroup } from '@edx/paragon';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { SwitchContent } from '../common';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './actions';
import { editableFieldSelector } from './selectors';
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
function EditableField(props) {
@@ -23,6 +23,7 @@ function EditableField(props) {
emptyLabel,
type,
value,
userSuppliedValue,
options,
saveState,
error,
@@ -66,15 +67,22 @@ function EditableField(props) {
const renderValue = (rawValue) => {
if (!rawValue) return renderEmptyLabel();
let value = rawValue;
if (options) {
// Use == instead of === to prevent issues when HTML casts numbers as strings
// eslint-disable-next-line eqeqeq
const selectedOption = options.find(option => option.value == rawValue);
if (selectedOption) return selectedOption.label;
if (selectedOption) {
value = selectedOption.label;
};
}
return rawValue;
if (userSuppliedValue) {
value += `: ${userSuppliedValue}`;
}
return value;
};
const renderConfirmationMessage = () => {
@@ -98,6 +106,7 @@ function EditableField(props) {
>
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
data-hj-suppress
name={name}
id={id}
type={type}
@@ -106,6 +115,7 @@ function EditableField(props) {
options={options}
{...others}
/>
<>{others.children}</>
</ValidationFormGroup>
<p>
<StatefulButton
@@ -146,7 +156,7 @@ function EditableField(props) {
</Button>
) : null}
</div>
<p>{renderValue(value)}</p>
<p data-hj-suppress>{renderValue(value)}</p>
<p className="small text-muted mt-n2">{renderConfirmationMessage() || helpText}</p>
</div>
),
@@ -162,6 +172,7 @@ EditableField.propTypes = {
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]),

View File

@@ -1,19 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button, StatefulButton, Input, ValidationFormGroup } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { Alert, SwitchContent } from '../common';
import Alert from './Alert';
import SwitchContent from './SwitchContent';
import messages from './AccountSettingsPage.messages';
import {
openForm,
closeForm,
} from './actions';
import { editableFieldSelector } from './selectors';
} from './data/actions';
import { editableFieldSelector } from './data/selectors';
function EmailField(props) {
@@ -108,6 +109,7 @@ function EmailField(props) {
>
<label className="h6 d-block" htmlFor={id}>{label}</label>
<Input
data-hj-suppress
name={name}
id={id}
type="email"
@@ -155,7 +157,7 @@ function EmailField(props) {
</Button>
) : null}
</div>
<p>{renderValue()}</p>
<p data-hj-suppress>{renderValue()}</p>
{renderConfirmationMessage() || <p className="small text-muted mt-n2">{helpText}</p>}
</div>
),

View File

@@ -1,18 +1,21 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { NavHashLink } from 'react-router-hash-link';
import Scrollspy from 'react-scrollspy';
import messages from './AccountSettingsPage.messages';
import { getConfig } from '@edx/frontend-platform';
import PropTypes from 'prop-types';
function JumpNav({ intl }) {
function JumpNav({ intl, displayDemographicsLink }) {
return (
<div className="jump-nav">
<Scrollspy
items={[
'basic-information',
'profile-information',
'demographics-information',
'social-media',
'site-preferences',
'linked-accounts',
@@ -31,6 +34,13 @@ function JumpNav({ intl }) {
{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'])}
@@ -59,7 +69,11 @@ function JumpNav({ intl }) {
JumpNav.propTypes = {
intl: intlShape.isRequired,
displayDemographicsLink: PropTypes.bool.isRequired,
};
JumpNav.defaultProps = {
displayDemographicsLink: false,
}
export default injectIntl(JumpNav);

View File

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

View File

@@ -20,6 +20,9 @@
}
li {
margin-bottom: .5rem;
a {
text-decoration: underline;
}
}
}
@@ -29,7 +32,16 @@
}
.account-section {
// These properties together will shift the hashlink position
margin-bottom: map-get($spacers, 5);
margin-bottom: map-get($spacers, 5);
padding-top: 1rem;
}
.custom-switch {
padding: 0;
max-width: 500px;
.custom-control-label {
left: 2.25rem;
line-height: 1.6rem;
}
}
}

View File

@@ -0,0 +1,268 @@
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,
saveState: undefined,
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,
saveState: PropTypes.string,
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,129 @@
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 className="w-100 btn-outline-primary" type="submit">
{props.intl.formatMessage(messages['account.settings.coaching.consent.accept-coaching'])}
</Button>
</div>
<div className="mt-3">
<Hyperlink
className="mt-3 text-dark btn-link small"
destination={props.redirectUrl}
onClick={props.declineCoaching}
>
{props.intl.formatMessage(messages['account.settings.coaching.consent.decline-coaching'])}
</Hyperlink>
</div>
</form>
</div>
</div>
);
CoachingForm.defaultProps = {
formErrors: {
coaching: '',
name: '',
phone_number: '',
},
};
CoachingForm.propTypes = {
intl: intlShape.isRequired,
onSubmit: PropTypes.func.isRequired,
declineCoaching: PropTypes.func.isRequired,
formValues: PropTypes.shape({
name: PropTypes.string,
phone_number: PropTypes.string,
coaching: PropTypes.shape({
coaching_consent: PropTypes.bool.isRequired,
user: PropTypes.number.isRequired,
eligible_for_coaching: PropTypes.bool.isRequired,
consent_form_seen: PropTypes.bool.isRequired,
}),
}).isRequired,
formErrors: PropTypes.shape({
coaching: PropTypes.string,
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,
intl: intlShape.isRequired,
};
export default injectIntl(CoachingForm);

View File

@@ -0,0 +1,99 @@
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,104 @@
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', () => {
return 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,282 @@
// 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="btn w-100 btn-outline-primary"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
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="btn w-100 btn-outline-primary"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
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

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

View File

@@ -1,9 +1,8 @@
import { utils } from '../common';
const { AsyncActionType } = utils;
import { AsyncActionType } from './utils';
export const FETCH_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_SETTINGS');
export const SAVE_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_SETTINGS');
export const SAVE_MULTIPLE_SETTINGS = new AsyncActionType('ACCOUNT_SETTINGS', 'SAVE_MULTIPLE_SETTINGS');
export const FETCH_TIME_ZONES = new AsyncActionType('ACCOUNT_SETTINGS', 'FETCH_TIME_ZONES');
export const SAVE_PREVIOUS_SITE_LANGUAGE = 'SAVE_PREVIOUS_SITE_LANGUAGE';
export const OPEN_FORM = 'OPEN_FORM';
@@ -101,6 +100,25 @@ export const savePreviousSiteLanguage = previousSiteLanguage => ({
payload: { previousSiteLanguage },
});
export const saveMultipleSettings = (settingsArray, form = null) => ({
type: SAVE_MULTIPLE_SETTINGS.BASE,
payload: { settingsArray, form },
});
export const saveMultipleSettingsBegin = () => ({
type: SAVE_MULTIPLE_SETTINGS.BEGIN,
});
export const saveMultipleSettingsSuccess = settingsArray => ({
type: SAVE_MULTIPLE_SETTINGS.SUCCESS,
payload: { settingsArray },
});
export const saveMultipleSettingsFailure = ({ fieldErrors, message }) => ({
type: SAVE_MULTIPLE_SETTINGS.FAILURE,
payload: { errors: fieldErrors, message },
});
// FETCH TIME_ZONE ACTIONS
export const fetchTimeZones = country => ({

View File

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

View File

@@ -7,12 +7,13 @@ import {
SAVE_PREVIOUS_SITE_LANGUAGE,
UPDATE_DRAFT,
RESET_DRAFTS,
SAVE_MULTIPLE_SETTINGS,
} from './actions';
import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from './delete-account';
import { reducer as siteLanguageReducer, FETCH_SITE_LANGUAGES } from './site-language';
import { reducer as resetPasswordReducer, RESET_PASSWORD } from './reset-password';
import { reducer as thirdPartyAuthReducer, DISCONNECT_AUTH } from './third-party-auth';
import { reducer as deleteAccountReducer, DELETE_ACCOUNT } from '../delete-account';
import { reducer as siteLanguageReducer, FETCH_SITE_LANGUAGES } from '../site-language';
import { reducer as resetPasswordReducer, RESET_PASSWORD } from '../reset-password';
import { reducer as thirdPartyAuthReducer, DISCONNECT_AUTH } from '../third-party-auth';
export const defaultState = {
loading: false,
@@ -144,6 +145,24 @@ const reducer = (state = defaultState, action) => {
...state,
previousSiteLanguage: action.payload.previousSiteLanguage,
};
case SAVE_MULTIPLE_SETTINGS.BEGIN:
return {
...state,
saveState: 'pending',
};
case SAVE_MULTIPLE_SETTINGS.SUCCESS:
return {
...state,
saveState: 'complete',
};
case SAVE_MULTIPLE_SETTINGS.FAILURE:
return {
...state,
saveState: 'error',
errors: Object.assign({}, state.errors, action.payload.errors),
};
case FETCH_TIME_ZONES.SUCCESS:
return {
@@ -151,8 +170,8 @@ const reducer = (state = defaultState, action) => {
countryTimeZones: action.payload.timeZones,
};
// TODO: Once all the above cases have been converted into sub-reducers, we can use
// combineReducers in this file to greatly simplify it.
// TODO: Once all the above cases have been converted into sub-reducers, we can use
// combineReducers in this file to greatly simplify it.
// Delete My Account
case DELETE_ACCOUNT.CONFIRMATION:
@@ -177,6 +196,7 @@ const reducer = (state = defaultState, action) => {
case RESET_PASSWORD.BEGIN:
case RESET_PASSWORD.SUCCESS:
case RESET_PASSWORD.FORBIDDEN:
return {
...state,
resetPassword: resetPasswordReducer(state.resetPassword, action),

View File

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

View File

@@ -1,24 +1,10 @@
import { createSelector, createStructuredSelector } from 'reselect';
import {
localeSelector,
getCountryList,
getLanguageList,
} from '@edx/frontend-i18n'; // eslint-disable-line
import { siteLanguageOptionsSelector, siteLanguageListSelector } from './site-language';
import { siteLanguageListSelector, siteLanguageOptionsSelector } from '../site-language';
export const storeName = 'accountSettings';
export const usernameSelector = state => state.authentication.username;
export const userRolesSelector = state => state.authentication.roles || [];
export const accountSettingsSelector = state => ({ ...state[storeName] });
const duplicateTpaProviderSelector = state => state.errors.duplicateTpaProvider;
const configurationSelector = state => state.configuration;
const editableFieldNameSelector = (state, props) => props.name;
const valuesSelector = createSelector(
@@ -54,6 +40,16 @@ const isEditingSelector = createSelector(
(name, accountSettings) => accountSettings.openFormId === name,
);
const confirmationValuesSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.confirmationValues,
);
const errorSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.errors,
);
const saveStateSelector = createSelector(
accountSettingsSelector,
accountSettings => accountSettings.saveState,
@@ -76,10 +72,6 @@ export const staticFieldsSelector = createSelector(
accountSettings => (accountSettings.profileDataManager ? ['name', 'email', 'country'] : []),
);
export const hiddenFieldsSelector = createSelector(
accountSettingsSelector,
accountSettings => (accountSettings.profileDataManager ? [] : ['secondary_email']),
);
/**
* If there's no draft present at all (undefined), use the original committed value.
@@ -100,17 +92,6 @@ const formValuesSelector = createSelector(
},
);
const countryOptionsSelector = createSelector(
localeSelector,
locale => getCountryList(locale).map(({ code, name }) => ({ value: code, label: name })),
);
const languageProficiencyOptionsSelector = createSelector(
localeSelector,
locale => getLanguageList(locale).map(({ code, name }) => ({ value: code, label: name })),
);
const transformTimeZonesToOptions = timeZoneArr => timeZoneArr
.map(({ time_zone, description }) => ({ // eslint-disable-line camelcase
value: time_zone, label: description,
@@ -131,69 +112,43 @@ const activeAccountSelector = createSelector(
accountSettings => accountSettings.values.is_active,
);
/**
* This selector converts the site language code back to the server version so that it can match up
* with one of the options in the site language dropdown. The drafts version will already be the
* server version, but if it's from localeSelector, it will be our client (two character) version.
*/
export const siteLanguageSelector = createSelector(
previousSiteLanguageSelector,
draftsSelector,
localeSelector,
(previousValue, drafts, locale) => ({
(previousValue, drafts) => ({
previousValue,
draftOrSavedValue: (drafts.siteLanguage !== undefined ? drafts.siteLanguage : locale),
savedValue: locale,
draft: drafts.siteLanguage,
}),
);
export const betaLanguageBannerSelector = createSelector(
siteLanguageListSelector,
siteLanguageSelector,
(
siteLanguageList,
siteLanguage,
) => ({
siteLanguageList,
siteLanguage,
}),
);
export const betaLanguageBannerSelector = createStructuredSelector({
siteLanguageList: siteLanguageListSelector,
siteLanguage: siteLanguageSelector,
});
export const accountSettingsPageSelector = createSelector(
accountSettingsSelector,
siteLanguageOptionsSelector,
siteLanguageSelector,
countryOptionsSelector,
languageProficiencyOptionsSelector,
formValuesSelector,
profileDataManagerSelector,
staticFieldsSelector,
hiddenFieldsSelector,
timeZonesSelector,
countryTimeZonesSelector,
activeAccountSelector,
duplicateTpaProviderSelector,
configurationSelector,
(
accountSettings,
siteLanguageOptions,
siteLanguage,
countryOptions,
languageProficiencyOptions,
formValues,
profileDataManager,
staticFields,
hiddenFields,
timeZoneOptions,
countryTimeZoneOptions,
activeAccount,
duplicateTpaProvider,
configuration,
) => ({
siteLanguageOptions,
siteLanguage,
countryOptions,
languageProficiencyOptions,
loading: accountSettings.loading,
loaded: accountSettings.loaded,
loadingError: accountSettings.loadingError,
@@ -203,10 +158,50 @@ export const accountSettingsPageSelector = createSelector(
formValues,
profileDataManager,
staticFields,
hiddenFields,
duplicateTpaProvider,
tpaProviders: accountSettings.thirdPartyAuth.providers,
supportUrl: configuration.SUPPORT_URL,
logoutUrl: configuration.LOGOUT_URL,
}),
);
export const coachingConsentPageSelector = createSelector(
accountSettingsSelector,
formValuesSelector,
activeAccountSelector,
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,22 +1,15 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import pick from 'lodash.pick';
import pickBy from 'lodash.pickby';
import omit from 'lodash.omit';
import isEmpty from 'lodash.isempty';
import { applyConfiguration, handleRequestError, unpackFieldErrors } from '../common/serviceUtils';
import { configureService as configureDeleteAccountApiService } from './delete-account';
import { configureService as configureResetPasswordApiService } from './reset-password';
import { configureService as configureSiteLanguageApiService } from './site-language';
import { configureService as configureThirdPartyAuthApiService, getThirdPartyAuthProviders } from './third-party-auth';
let config = {
BASE_URL: null,
ACCOUNTS_API_BASE_URL: null,
PREFERENCES_API_BASE_URL: null,
ECOMMERCE_API_BASE_URL: null,
LMS_BASE_URL: null,
DELETE_ACCOUNT_URL: null,
PASSWORD_RESET_URL: null,
};
import { handleRequestError, unpackFieldErrors } from './utils';
import { getThirdPartyAuthProviders } from '../third-party-auth';
import { getCoachingPreferences, patchCoachingPreferences } from '../coaching/data/service';
import { getDemographics, getDemographicsOptions, patchDemographics } from '../demographics/data/service';
import { DEMOGRAPHICS_FIELDS } from '../demographics/data/utils';
const SOCIAL_PLATFORMS = [
{ id: 'twitter', key: 'social_link_twitter' },
@@ -24,18 +17,6 @@ const SOCIAL_PLATFORMS = [
{ id: 'linkedin', key: 'social_link_linkedin' },
];
let apiClient = null;
export function configureService(newConfig, newApiClient) {
config = applyConfiguration(config, newConfig);
apiClient = newApiClient;
configureDeleteAccountApiService(config, apiClient);
configureResetPasswordApiService(config, apiClient);
configureSiteLanguageApiService(config, apiClient);
configureThirdPartyAuthApiService(config, apiClient);
}
function unpackAccountResponseData(data) {
const unpackedData = data;
@@ -90,7 +71,8 @@ function packAccountCommitData(commitData) {
}
export async function getAccount(username) {
const { data } = await apiClient.get(`${config.ACCOUNTS_API_BASE_URL}/${username}`);
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
return unpackAccountResponseData(data);
}
@@ -99,9 +81,9 @@ export async function patchAccount(username, commitValues) {
headers: { 'Content-Type': 'application/merge-patch+json' },
};
const { data } = await apiClient
const { data } = await getAuthenticatedHttpClient()
.patch(
`${config.ACCOUNTS_API_BASE_URL}/${username}`,
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`,
packAccountCommitData(commitValues),
requestConfig,
)
@@ -122,23 +104,25 @@ export async function patchAccount(username, commitValues) {
}
export async function getPreferences(username) {
const { data } = await apiClient.get(`${config.PREFERENCES_API_BASE_URL}/${username}`);
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
return data;
}
export async function patchPreferences(username, commitValues) {
const requestConfig = { headers: { 'Content-Type': 'application/merge-patch+json' } };
const requestUrl = `${config.PREFERENCES_API_BASE_URL}/${username}`;
const requestUrl = `${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`;
// Ignore the success response, the API does not currently return any data.
await apiClient.patch(requestUrl, commitValues, requestConfig).catch(handleRequestError);
await getAuthenticatedHttpClient()
.patch(requestUrl, commitValues, requestConfig).catch(handleRequestError);
return commitValues;
}
export async function getTimeZones(forCountry) {
const { data } = await apiClient
.get(`${config.LMS_BASE_URL}/user_api/v1/preferences/time_zones/`, {
const { data } = await getAuthenticatedHttpClient()
.get(`${getConfig().LMS_BASE_URL}/user_api/v1/preferences/time_zones/`, {
params: { country_code: forCountry },
})
.catch(handleRequestError);
@@ -153,15 +137,14 @@ export async function getProfileDataManager(username, userRoles) {
const userRoleNames = userRoles.map(role => role.split(':')[0]);
if (userRoleNames.includes('enterprise_learner')) {
const url = `${config.LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/?username=${username}`;
const { data } = await apiClient.get(url).catch(handleRequestError);
const url = `${getConfig().LMS_BASE_URL}/enterprise/api/v1/enterprise-learner/?username=${username}`;
const { data } = await getAuthenticatedHttpClient().get(url).catch(handleRequestError);
if ('results' in data) {
for (let i = 0; i < data.results.length; i += 1) {
const enterprise = data.results[i].enterprise_customer;
if (enterprise.sync_learner_profile_data) {
return enterprise.name;
}
if (data.results.length > 0) {
const enterprise = data.results[0] && data.results[0].enterprise_customer;
// To ensure that enterprise returned is current enterprise & it manages profile settings
if (enterprise && enterprise.sync_learner_profile_data) {
return enterprise.name;
}
}
}
@@ -170,16 +153,42 @@ export async function getProfileDataManager(username, userRoles) {
}
/**
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
* 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 getSettings(username, userRoles) {
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;
}
/**
* A single function to GET everything considered a setting.
* Currently encapsulates Account, Preferences, Coaching, ThirdPartyAuth, and Demographics
*/
export async function getSettings(username, userRoles, userId) {
const results = await Promise.all([
getAccount(username),
getPreferences(username),
getThirdPartyAuthProviders(),
getProfileDataManager(username, userRoles),
getTimeZones(),
getConfig().COACHING_ENABLED && getCoachingPreferences(userId),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && shouldDisplayDemographicsQuestions(),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographics(userId),
getConfig().ENABLE_DEMOGRAPHICS_COLLECTION && getDemographicsOptions(),
]);
return {
@@ -188,20 +197,29 @@ export async function getSettings(username, userRoles) {
thirdPartyAuthProviders: results[2],
profileDataManager: results[3],
timeZones: results[4],
coaching: results[5],
shouldDisplayDemographicsSection: results[6],
...results[7], // demographics
demographicsOptions: results[8],
};
}
/**
* A single function to PATCH everything considered a setting.
* Currently encapsulates Account, Preferences, and ThirdPartyAuth
* Currently encapsulates Account, Preferences, coaching and ThirdPartyAuth
*/
export async function patchSettings(username, commitValues) {
export async function patchSettings(username, commitValues, userId) {
// Note: time_zone exists in the return value from user/v1/accounts
// but it is always null and won't update. It also exists in
// user/v1/preferences where it does update. This is the one we use.
const preferenceKeys = ['time_zone'];
const accountCommitValues = omit(commitValues, preferenceKeys);
const coachingKeys = ['coaching'];
const demographicsKeys = DEMOGRAPHICS_FIELDS;
const isDemographicsKey = (value, key) => key.includes('demographics');
const accountCommitValues = omit(commitValues, preferenceKeys, coachingKeys, demographicsKeys);
const preferenceCommitValues = pick(commitValues, preferenceKeys);
const coachingCommitValues = pick(commitValues, coachingKeys);
const demographicsCommitValues = pickBy(commitValues, isDemographicsKey);
const patchRequests = [];
if (!isEmpty(accountCommitValues)) {
@@ -210,6 +228,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));
}
const results = await Promise.all(patchRequests);
// Assigns in order of requests. Preference keys
@@ -217,4 +241,3 @@ export async function patchSettings(username, commitValues) {
const combinedResults = Object.assign({}, ...results);
return combinedResults;
}

View File

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

View File

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

View File

@@ -1,12 +1,9 @@
import {
AsyncActionType,
modifyObjectKeys,
camelCaseObject,
snakeCaseObject,
convertKeyNames,
keepKeys,
getModuleState,
} from './utils';
} from './dataUtils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
@@ -91,77 +88,3 @@ describe('convertKeyNames', () => {
});
});
});
describe('keepKeys', () => {
it('should keep the specified keys only', () => {
const result = keepKeys(
{
one: 123,
two: { three: 'skip me' },
four: 'five',
six: null,
8: 'sneaky',
},
[
'one',
'three',
'six',
'seven',
'8', // yup, the 8 integer will be converted to a string.
],
);
expect(result).toEqual({
one: 123,
six: null,
8: 'sneaky',
});
});
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
});
});
describe('getModuleState', () => {
const state = {
first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
second: { other: 'data' },
};
it('should return everything if given an empty path', () => {
expect(getModuleState(state, [])).toEqual(state);
});
it('should resolve paths correctly', () => {
expect(getModuleState(
state,
['first'],
)).toEqual({ red: { awesome: 'sauce' }, blue: { weak: 'sauce' } });
expect(getModuleState(
state,
['first', 'red'],
)).toEqual({ awesome: 'sauce' });
expect(getModuleState(state, ['second'])).toEqual({ other: 'data' });
});
it('should throw an exception on a bad path', () => {
expect(() => {
getModuleState(state, ['uhoh']);
}).toThrowErrorMatchingSnapshot();
});
it('should return non-objects correctly', () => {
expect(getModuleState(state, ['first', 'red', 'awesome'])).toEqual('sauce');
});
});
});

View File

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

View File

@@ -1,50 +1,36 @@
import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase';
export function modifyObjectKeys(object, modify) {
// If the passed in object is not an object, return it.
if (
object === undefined ||
object === null ||
(typeof object !== 'object' && !Array.isArray(object))
) {
return object;
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*/
export class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
if (Array.isArray(object)) {
return object.map(value => modifyObjectKeys(value, modify));
get BASE() {
return `${this.topic}__${this.name}`;
}
// Otherwise, process all its keys.
const result = {};
Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify);
});
return result;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
export function camelCaseObject(object) {
return modifyObjectKeys(object, camelCase);
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
export function snakeCaseObject(object) {
return modifyObjectKeys(object, snakeCase);
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
export function convertKeyNames(object, nameMap) {
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
return modifyObjectKeys(object, transformer);
}
export function keepKeys(data, whitelist) {
const result = {};
Object.keys(data).forEach((key) => {
if (whitelist.indexOf(key) > -1) {
result[key] = data[key];
}
});
return result;
get FORBIDDEN() {
return `${this.topic}__${this.name}__FORBIDDEN`;
}
}
/**
@@ -78,36 +64,3 @@ export function getModuleState(state, originalPath) {
}
return getModuleState(state[key], path);
}
/**
* Helper class to save time when writing out action types for asynchronous methods. Also helps
* ensure that actions are namespaced.
*
* TODO: Put somewhere common to it can be used by other MFEs.
*/
export class AsyncActionType {
constructor(topic, name) {
this.topic = topic;
this.name = name;
}
get BASE() {
return `${this.topic}__${this.name}`;
}
get BEGIN() {
return `${this.topic}__${this.name}__BEGIN`;
}
get SUCCESS() {
return `${this.topic}__${this.name}__SUCCESS`;
}
get FAILURE() {
return `${this.topic}__${this.name}__FAILURE`;
}
get RESET() {
return `${this.topic}__${this.name}__RESET`;
}
}

View File

@@ -0,0 +1,52 @@
import {
AsyncActionType,
getModuleState,
} from './reduxUtils';
describe('AsyncActionType', () => {
it('should return well formatted action strings', () => {
const actionType = new AsyncActionType('HOUSE_CATS', 'START_THE_RACE');
expect(actionType.BASE).toBe('HOUSE_CATS__START_THE_RACE');
expect(actionType.BEGIN).toBe('HOUSE_CATS__START_THE_RACE__BEGIN');
expect(actionType.SUCCESS).toBe('HOUSE_CATS__START_THE_RACE__SUCCESS');
expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE');
expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET');
expect(actionType.FORBIDDEN).toBe('HOUSE_CATS__START_THE_RACE__FORBIDDEN');
});
});
describe('getModuleState', () => {
const state = {
first: { red: { awesome: 'sauce' }, blue: { weak: 'sauce' } },
second: { other: 'data' },
};
it('should return everything if given an empty path', () => {
expect(getModuleState(state, [])).toEqual(state);
});
it('should resolve paths correctly', () => {
expect(getModuleState(
state,
['first'],
)).toEqual({ red: { awesome: 'sauce' }, blue: { weak: 'sauce' } });
expect(getModuleState(
state,
['first', 'red'],
)).toEqual({ awesome: 'sauce' });
expect(getModuleState(state, ['second'])).toEqual({ other: 'data' });
});
it('should throw an exception on a bad path', () => {
expect(() => {
getModuleState(state, ['uhoh']);
}).toThrowErrorMatchingSnapshot();
});
it('should return non-objects correctly', () => {
expect(getModuleState(state, ['first', 'red', 'awesome'])).toEqual('sauce');
});
});

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-i18n';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Hyperlink } from '@edx/paragon';
@@ -9,10 +9,10 @@ import { Hyperlink } from '@edx/paragon';
import messages from './messages';
// Components
import { Alert } from '../../common';
import Alert from '../Alert';
const BeforeProceedingBanner = (props) => {
const { instructionMessageId, intl, supportUrl } = props;
const { instructionMessageId, intl, supportArticleUrl } = props;
return (
<Alert
@@ -25,7 +25,7 @@ const BeforeProceedingBanner = (props) => {
description="Error that appears if you are trying to delete your edX account, but something about your account needs attention first. The actionLink will be instructions, such as 'unlink your Facebook account'."
values={{
actionLink: (
<Hyperlink destination={supportUrl}>
<Hyperlink destination={supportArticleUrl}>
{intl.formatMessage(messages[instructionMessageId])}
</Hyperlink>
),
@@ -38,7 +38,7 @@ const BeforeProceedingBanner = (props) => {
BeforeProceedingBanner.propTypes = {
instructionMessageId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
supportUrl: PropTypes.string.isRequired,
supportArticleUrl: PropTypes.string.isRequired,
};
export default injectIntl(BeforeProceedingBanner);

View File

@@ -2,12 +2,12 @@ import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Button, Input, Modal, ValidationFormGroup } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { faExclamationCircle, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import messages from './messages';
import { Alert } from '../../common';
import Alert from '../Alert';
import PrintingInstructions from './PrintingInstructions';
export class ConfirmationModal extends Component {
@@ -19,6 +19,8 @@ export class ConfirmationModal extends Component {
switch (reason) {
case 'empty-password':
return 'account.settings.delete.account.error.no.password';
case 'invalid-password':
return 'account.settings.delete.account.error.invalid.password';
default:
return 'account.settings.delete.account.error.unable.to.delete';
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-i18n';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;
@@ -21,7 +21,6 @@ describe('ConfirmationModal', () => {
status: null,
errorType: null,
password: 'fluffy bunnies',
logoutUrl: 'http://localhost/logout',
};
});

View File

@@ -1,7 +1,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink } from '@edx/paragon';
// Actions
@@ -46,7 +47,7 @@ export class DeleteAccount extends React.Component {
};
handleFinalClose = () => {
global.location = this.props.logoutUrl;
global.location = getConfig().LOGOUT_URL;
};
render() {
@@ -87,14 +88,14 @@ export class DeleteAccount extends React.Component {
{isVerifiedAccount ? null : (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.activate"
supportUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/115000940568-How-do-I-activate-my-account-"
/>
)}
{hasLinkedTPA ? (
<BeforeProceedingBanner
instructionMessageId="account.settings.delete.account.please.unlink"
supportUrl="https://support.edx.org/hc/en-us/articles/207206067"
supportArticleUrl="https://support.edx.org/hc/en-us/articles/207206067"
/>
) : null}
@@ -123,7 +124,6 @@ DeleteAccount.propTypes = {
errorType: PropTypes.oneOf(['empty-password', 'server']),
hasLinkedTPA: PropTypes.bool,
isVerifiedAccount: PropTypes.bool,
logoutUrl: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-i18n';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Testing the modals separately, they just clutter up the snapshots if included here.
jest.mock('./ConfirmationModal');
@@ -24,7 +24,6 @@ describe('DeleteAccount', () => {
errorType: null,
hasLinkedTPA: false,
isVerifiedAccount: true,
logoutUrl: 'http://localhost/logout',
};
});

View File

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

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Modal } from '@edx/paragon';
import messages from './messages';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-i18n';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
ReactDOM.createPortal = node => node;

View File

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

View File

@@ -8,11 +8,11 @@ exports[`SuccessModal should match default closed success modal snapshot 1`] = `
/>
<div
className="modal js-close-modal-on-click fade"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id3"
aria-labelledby="id2"
aria-modal={true}
className=""
role="dialog"
@@ -26,7 +26,7 @@ exports[`SuccessModal should match default closed success modal snapshot 1`] = `
>
<h2
className="modal-title"
id="id3"
id="id2"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
@@ -47,6 +47,7 @@ exports[`SuccessModal should match default closed success modal snapshot 1`] = `
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton1"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@@ -69,7 +70,7 @@ exports[`SuccessModal should match default closed success modal snapshot 2`] = `
/>
<div
className="modal js-close-modal-on-click fade"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
@@ -108,6 +109,7 @@ exports[`SuccessModal should match default closed success modal snapshot 2`] = `
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton3"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@@ -130,68 +132,7 @@ exports[`SuccessModal should match default closed success modal snapshot 3`] = `
/>
<div
className="modal js-close-modal-on-click fade"
onClick={[Function]}
role="presentation"
>
<div
aria-labelledby="id5"
aria-modal={true}
className=""
role="dialog"
tabIndex="-1"
>
<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 js-close-modal-on-click btn-secondary"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`SuccessModal should match default closed success modal snapshot 4`] = `
<div>
<div
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click fade"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
@@ -230,6 +171,69 @@ exports[`SuccessModal should match default closed success modal snapshot 4`] = `
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton5"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
type="button"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
`;
exports[`SuccessModal should match default closed success modal snapshot 4`] = `
<div>
<div
className="fade"
role="presentation"
/>
<div
className="modal js-close-modal-on-click fade"
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id8"
aria-modal={true}
className=""
role="dialog"
tabIndex="-1"
>
<div
className="modal-content"
>
<div
className="modal-header"
>
<h2
className="modal-title"
id="id8"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
</div>
<div
className="modal-body"
>
<div>
<p
className="h6"
>
Account deletion, including removal from email lists, may take a few weeks to fully process through our system. If you want to opt-out of emails before then, please unsubscribe from the footer of any email.
</p>
</div>
</div>
<div
className="modal-footer"
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton7"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}
@@ -252,11 +256,11 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
/>
<div
className="modal js-close-modal-on-click show d-block"
onClick={[Function]}
onMouseDown={[Function]}
role="presentation"
>
<div
aria-labelledby="id7"
aria-labelledby="id10"
aria-modal={true}
className="modal-dialog"
role="dialog"
@@ -270,7 +274,7 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
>
<h2
className="modal-title"
id="id7"
id="id10"
>
We're sorry to see you go! Your account will be deleted shortly.
</h2>
@@ -291,6 +295,7 @@ exports[`SuccessModal should match open success modal snapshot 1`] = `
>
<button
className="btn js-close-modal-on-click btn-secondary"
id="paragonCloseModalButton9"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[Function]}

View File

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

View File

@@ -15,7 +15,9 @@ export function* handleDeleteAccount(action) {
const response = yield call(postDeleteAccount, action.payload.password);
yield put(deleteAccountSuccess(response));
} catch (e) {
if (typeof e.response.data === 'string') {
if (e.response.status === 403) {
yield put(deleteAccountFailure('invalid-password'));
} else if (typeof e.response.data === 'string') {
yield put(deleteAccountFailure());
} else {
throw e;

View File

@@ -1,24 +1,16 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import formurlencoded from 'form-urlencoded';
import { applyConfiguration, handleRequestError } from '../../../common/serviceUtils';
let config = {
DELETE_ACCOUNT_URL: null,
};
let apiClient = null;
export function configureService(newConfig, newApiClient) {
config = applyConfiguration(config, newConfig);
apiClient = newApiClient;
}
import { handleRequestError } from '../../data/utils';
/**
* Request deletion of the user's account.
*/
// eslint-disable-next-line import/prefer-default-export
export async function postDeleteAccount(password) {
const { data } = await apiClient
const { data } = await getAuthenticatedHttpClient()
.post(
config.DELETE_ACCOUNT_URL,
`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/deactivate_logout/`,
formurlencoded({ password }),
{
headers: {

View File

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

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-i18n';
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'account.settings.delete.account.header': {
@@ -91,6 +91,11 @@ const messages = defineMessages({
defaultMessage: 'A password is required',
description: 'Error message when user has not entered their password',
},
'account.settings.delete.account.error.invalid.password': {
id: 'account.settings.delete.account.error.invalid.password',
defaultMessage: 'Password is incorrect',
description: 'Error message when user has entered incorrect password',
},
'account.settings.delete.account.error.unable.to.delete.details': {
id: 'account.settings.delete.account.error.unable.to.delete.details',
defaultMessage: 'Sorry, there was an error trying to process your request. Please try again later.',

View File

@@ -0,0 +1,78 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { CheckBox } from '@edx/paragon';
import { DECLINED } from '../data/constants';
export 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 = () => {
return options.map((option, index) => {
const isFirst = index == 0;
const isChecked = selected.includes(option.value);
return (
<div key={index} 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,
options: PropTypes.array,
values: PropTypes.array,
onChange: PropTypes.func,
};
Checkboxes.defaultProps = {
options: [],
values: [],
}
export default Checkboxes;

View File

@@ -0,0 +1,331 @@
import {
OTHER,
SELF_DESCRIBE,
} from '../data/constants';
import { getConfig } from '@edx/frontend-platform';
import {
FormattedMessage,
injectIntl,
intlShape,
} from '@edx/frontend-platform/i18n';
import { saveMultipleSettings, updateDraft } from '../data/actions';
import Alert from '../Alert';
import Checkboxes from './Checkboxes';
import EditableField from '../EditableField';
import { Input } from '@edx/paragon';
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { demographicsSectionSelector } from '../data/selectors';
import get from 'lodash.get';
import isEmpty from 'lodash.isempty';
import memoize from 'memoize-one';
import messages from './DemographicsSection.messages';
class DemographicsSection extends React.Component {
constructor(props, context) {
super(props, context);
}
/**
* 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);
}
/**
* 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]),
}];
}
// 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) => {
if (get(this, 'props.formValues.demographics_user_ethnicity')) {
const ethnicities = this.props.formValues.demographics_user_ethnicity;
return ethnicities.map((e) => {
var matchingOption = demographicsEthnicityOptions.filter(option => option.value === e)[0];
return matchingOption && matchingOption.label;
}).join(", ")
}
}
handleEditableFieldChange = (name, value) => {
this.props.updateDraft(name, value);
};
handleSubmit = (formId, values) => {
// 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.drafts;
const settingsArray = []
for (let field in drafts) {
settingsArray.push({
formId: field,
commitValues: drafts[field]
})
}
this.props.saveMultipleSettings(settingsArray, formId);
};
/**
* 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() == false) {
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>
);
} else {
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>
<a href={getConfig().MARKETING_SITE_BASE_URL + '/demographics'} target="_blank">
{this.props.intl.formatMessage(messages['account.settings.section.demographics.why'])}
</a>
</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,
}).isRequired,
formErrors: PropTypes.shape({
demographicsError: PropTypes.string,
}).isRequired,
updateDraft: 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 edX collect this information?',
description: 'Link text for a link to external legal text',
},
});
export default messages;

View File

@@ -0,0 +1,141 @@
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 DemographicsSection from '../DemographicsSection';
import { Provider } from 'react-redux';
import React from 'react';
import configureStore from 'redux-mock-store';
import renderer from 'react-test-renderer';
jest.mock('@edx/frontend-platform/auth');
const IntlDemographicsSection = injectIntl(DemographicsSection);
jest.mock('../../data/selectors', () => {
return 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: {},
};
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: ""
}
}
const wrapper = renderer.create(reduxWrapper(<IntlDemographicsSection {...props} />)).toJSON();
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,3688 @@
// 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
href="http://localhost:5335/demographics"
target="_blank"
>
Why does edX collect this information?
</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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
data-hj-suppress={true}
>
<button
className="btn btn-link p-0"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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
href="http://localhost:5335/demographics"
target="_blank"
>
Why does edX collect this information?
</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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
data-hj-suppress={true}
>
<button
className="btn btn-link p-0"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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
href="http://localhost:5335/demographics"
target="_blank"
>
Why does edX collect this information?
</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
href="http://localhost:5335/demographics"
target="_blank"
>
Why does edX collect this information?
</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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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
href="http://localhost:5335/demographics"
target="_blank"
>
Why does edX collect this information?
</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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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
href="http://localhost:5335/demographics"
target="_blank"
>
Why does edX collect this information?
</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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
data-hj-suppress={true}
>
<button
className="btn btn-link p-0"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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
href="http://localhost:5335/demographics"
target="_blank"
>
Why does edX collect this information?
</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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
data-hj-suppress={true}
>
<button
className="btn btn-link p-0"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
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="btn ml-3 btn-link"
onBlur={[Function]}
onClick={[Function]}
onKeyDown={[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
data-hj-suppress={true}
>
Prefer not to respond
</p>
<p
className="small text-muted mt-n2"
/>
</div>
</div>
</div>
</div>
</div>
`;

View File

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

View File

@@ -1,11 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@edx/frontend-i18n';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@edx/paragon';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { Alert } from '../../common';
import Alert from '../Alert';
const ConfirmationAlert = (props) => {
const { email } = props;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import Alert from '../Alert';
const RequestInProgressAlert = (props) => {
return (
<Alert
className="alert-warning mt-n2"
icon={<FontAwesomeIcon className="mr-2" icon={faExclamationTriangle} />}
>
<FormattedMessage
id="account.settings.editable.field.password.reset.button.forbidden"
defaultMessage="Your previous request is in progress, please try again in few moments."
description="A message displayed when a previous password reset request is still in progress."
/>
</Alert>
);
};
export default RequestInProgressAlert;

View File

@@ -1,12 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-i18n';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { StatefulButton } from '@edx/paragon';
import { resetPassword } from './data/actions';
import messages from './messages';
import ConfirmationAlert from './ConfirmationAlert';
import RequestInProgressAlert from './RequestInProgressAlert';
const ResetPassword = (props) => {
const { email, intl, status } = props;
@@ -43,6 +44,7 @@ const ResetPassword = (props) => {
/>
</p>
{status === 'complete' ? <ConfirmationAlert email={email} /> : null}
{status === 'forbidden' ? <RequestInProgressAlert /> : null}
</div>
);
};

View File

@@ -1,6 +1,4 @@
import { utils } from '../../../common';
const { AsyncActionType } = utils;
import { AsyncActionType } from '../../data/utils';
export const RESET_PASSWORD = new AsyncActionType('ACCOUNT_SETTINGS', 'RESET_PASSWORD');
@@ -20,3 +18,7 @@ export const resetPasswordSuccess = () => ({
export const resetPasswordReset = () => ({
type: RESET_PASSWORD.RESET,
});
export const resetPasswordForbidden = () => ({
type: RESET_PASSWORD.FORBIDDEN,
});

View File

@@ -17,6 +17,11 @@ const reducer = (state = defaultState, action = null) => {
...state,
status: 'complete',
};
case RESET_PASSWORD.FORBIDDEN:
return {
...state,
status: 'forbidden',
};
default:
}

View File

@@ -1,12 +1,20 @@
import { put, call, takeEvery } from 'redux-saga/effects';
import { resetPasswordBegin, resetPasswordSuccess, RESET_PASSWORD } from './actions';
import { resetPasswordBegin, resetPasswordForbidden, resetPasswordSuccess, RESET_PASSWORD } from './actions';
import { postResetPassword } from './service';
function* handleResetPassword(action) {
yield put(resetPasswordBegin());
const response = yield call(postResetPassword, action.payload.email);
yield put(resetPasswordSuccess(response));
try {
const response = yield call(postResetPassword, action.payload.email);
yield put(resetPasswordSuccess(response));
} catch (error) {
if (error.response && error.response.status === 403) {
yield put(resetPasswordForbidden(error));
} else {
throw error;
}
}
}
export default function* saga() {

View File

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

View File

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

View File

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

View File

@@ -1,111 +0,0 @@
import { call, put, delay, takeEvery, select, all } from 'redux-saga/effects';
// Actions
import {
FETCH_SETTINGS,
fetchSettingsBegin,
fetchSettingsSuccess,
fetchSettingsFailure,
closeForm,
SAVE_SETTINGS,
saveSettingsBegin,
saveSettingsSuccess,
saveSettingsFailure,
savePreviousSiteLanguage,
FETCH_TIME_ZONES,
fetchTimeZones,
fetchTimeZonesSuccess,
} from './actions';
import { usernameSelector, userRolesSelector, siteLanguageSelector } from './selectors';
// Sub-modules
import { saga as deleteAccountSaga } from './delete-account';
import { saga as resetPasswordSaga } from './reset-password';
import { saga as siteLanguageSaga, ApiService as SiteLanguageApiService } from './site-language';
import { saga as thirdPartyAuthSaga } from './third-party-auth';
// Services
import * as ApiService from './service';
import { setLocale, handleRtl } from '@edx/frontend-i18n'; // eslint-disable-line
export function* handleFetchSettings() {
try {
yield put(fetchSettingsBegin());
const username = yield select(usernameSelector);
const userRoles = yield select(userRolesSelector);
const {
thirdPartyAuthProviders, profileDataManager, timeZones, ...values
} = yield call(
ApiService.getSettings,
username,
userRoles,
);
if (values.country) yield put(fetchTimeZones(values.country));
yield put(fetchSettingsSuccess({
values,
thirdPartyAuthProviders,
profileDataManager,
timeZones,
}));
} catch (e) {
yield put(fetchSettingsFailure(e.message));
throw e;
}
}
export function* handleSaveSettings(action) {
try {
yield put(saveSettingsBegin());
const username = yield select(usernameSelector);
const { commitValues, formId } = action.payload;
const commitData = { [formId]: commitValues };
let savedValues = null;
if (formId === 'siteLanguage') {
const previousSiteLanguage = yield select(siteLanguageSelector);
yield all([
call(SiteLanguageApiService.patchPreferences, username, { prefLang: commitValues }),
call(SiteLanguageApiService.postSetLang, commitValues),
]);
yield put(setLocale(commitValues));
yield put(savePreviousSiteLanguage(previousSiteLanguage.savedValue));
handleRtl();
savedValues = commitData;
} else {
savedValues = yield call(ApiService.patchSettings, username, commitData);
}
yield put(saveSettingsSuccess(savedValues, commitData));
if (savedValues.country) yield put(fetchTimeZones(savedValues.country));
yield delay(1000);
yield put(closeForm(action.payload.formId));
} catch (e) {
if (e.fieldErrors) {
yield put(saveSettingsFailure({ fieldErrors: e.fieldErrors }));
} else {
yield put(saveSettingsFailure(e.message));
throw e;
}
}
}
export function* handleFetchTimeZones(action) {
const response = yield call(ApiService.getTimeZones, action.payload.country);
yield put(fetchTimeZonesSuccess(response, action.payload.country));
}
export default function* saga() {
yield takeEvery(FETCH_SETTINGS.BASE, handleFetchSettings);
yield takeEvery(SAVE_SETTINGS.BASE, handleSaveSettings);
yield takeEvery(FETCH_TIME_ZONES.BASE, handleFetchTimeZones);
yield all([
deleteAccountSaga(),
siteLanguageSaga(),
resetPasswordSaga(),
thirdPartyAuthSaga(),
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
import React from 'react';
import renderer from 'react-test-renderer';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import JumpNav from "../JumpNav";
import { BrowserRouter as Router } from 'react-router-dom';
import { mergeConfig, setConfig } from '@edx/frontend-platform';
const IntlJumpNav = injectIntl(JumpNav);
describe('JumpNav', () => {
mergeConfig({
ENABLE_DEMOGRAPHICS_COLLECTION: false,
});
let props = {};
beforeEach(() => {
props = {
intl: {},
displayDemographicsLink: false,
};
});
it('should not render Optional Information link', () => {
const tree = renderer.create((
// Had to wrap the following in a router or I will receive an error stating:
// "Invariant failed: You should not use <NavLink> outside a <Router>"
<Router>
<IntlProvider locale="en">
<IntlJumpNav {...props} />
</IntlProvider>
</Router>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
it('should render Optional Information link', () => {
setConfig({
ENABLE_DEMOGRAPHICS_COLLECTION: true,
});
props = {
...props,
displayDemographicsLink: true,
}
const tree = renderer.create((
// Same as previous test
<Router>
<IntlProvider locale="en">
<IntlJumpNav {...props} />
</IntlProvider>
</Router>
))
.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

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