Compare commits
338 Commits
zshkoor/re
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfb6b0d2a1 | ||
|
|
5a3498c6eb | ||
|
|
b0eae68a3b | ||
|
|
5fbf78956d | ||
|
|
bd44d4e240 | ||
|
|
3b4a710ace | ||
|
|
b2bfbab095 | ||
|
|
e5f57ec603 | ||
|
|
665522d59d | ||
|
|
1d8d2d6cde | ||
|
|
0227efdb0f | ||
|
|
0b21cc18ed | ||
|
|
0eaf2f1c4e | ||
|
|
ea1982abd8 | ||
|
|
2c94e48bd0 | ||
|
|
1737d2f6f9 | ||
|
|
f94d055bd6 | ||
|
|
a864511756 | ||
|
|
2ae4cf08de | ||
|
|
438e937ad4 | ||
|
|
223d346ce3 | ||
|
|
929ba69503 | ||
|
|
16de1d6a40 | ||
|
|
f04aaa0daa | ||
|
|
bb88c0954d | ||
|
|
ab6732038e | ||
|
|
72a2842acc | ||
|
|
1ba4d6bfb3 | ||
|
|
afa826121b | ||
|
|
9197d0d6ec | ||
|
|
1ee2fe873a | ||
|
|
64dcab37ed | ||
|
|
ae0cb99cdf | ||
|
|
29bdb7d176 | ||
|
|
5583c1589b | ||
|
|
7433b44059 | ||
|
|
df11ee6fb8 | ||
|
|
e08014c656 | ||
|
|
6fd8b2506c | ||
|
|
cf382b89ed | ||
|
|
9306d45284 | ||
|
|
773fdaba28 | ||
|
|
b07a7602e4 | ||
|
|
d0416cdcd2 | ||
|
|
8f78079112 | ||
|
|
4956d8966f | ||
|
|
20eb6b9de3 | ||
|
|
b43f088f9f | ||
|
|
5f4d620b1e | ||
|
|
a7ef931ef5 | ||
|
|
1ac78e2752 | ||
|
|
2867ea653b | ||
|
|
3f71adec02 | ||
|
|
e33573e503 | ||
|
|
1b2b34e0e4 | ||
|
|
5f34256118 | ||
|
|
d9ab9a9c4c | ||
|
|
29979a57e1 | ||
|
|
97144ba002 | ||
|
|
10bb6e1b3e | ||
|
|
4a37b68550 | ||
|
|
f50c77e051 | ||
|
|
67c3ce6402 | ||
|
|
3a7a443d5b | ||
|
|
7c7e472fb2 | ||
|
|
c74c62f5a0 | ||
|
|
8e1c4f06c4 | ||
|
|
d2ed3e54ee | ||
|
|
c73d1f96a0 | ||
|
|
8b88de618d | ||
|
|
872fa4c917 | ||
|
|
cdf19f4ba5 | ||
|
|
67af9d0f8d | ||
|
|
7ac0e21741 | ||
|
|
25a0f08850 | ||
|
|
cb0af0df7b | ||
|
|
23f8b5a58c | ||
|
|
ea50a3ec10 | ||
|
|
31a0e43a92 | ||
|
|
927fb56c8b | ||
|
|
1f88d5752f | ||
|
|
58e14019dc | ||
|
|
e373c7da75 | ||
|
|
2b3857d922 | ||
|
|
863937be88 | ||
|
|
9d5a89d4c6 | ||
|
|
762e29047f | ||
|
|
fa7267f17c | ||
|
|
7b754edef8 | ||
|
|
0bb7ee2fd4 | ||
|
|
335dd7819d | ||
|
|
b18a01c302 | ||
|
|
981dccf2d5 | ||
|
|
e8be148ca9 | ||
|
|
167a8bd9a8 | ||
|
|
db2336ac09 | ||
|
|
a13c25d4ea | ||
|
|
9452b72525 | ||
|
|
3601cb6c05 | ||
|
|
b73c0f0f26 | ||
|
|
37feffc0db | ||
|
|
c9d2813009 | ||
|
|
8ec67d9ed2 | ||
|
|
1d149f12ea | ||
|
|
9516ee0e92 | ||
|
|
29fd7176c8 | ||
|
|
16ddd7abba | ||
|
|
577ef6ab0b | ||
|
|
8268fa4eab | ||
|
|
5665f8a0d6 | ||
|
|
4b7a3207e0 | ||
|
|
3db0289aab | ||
|
|
85d3eca9e4 | ||
|
|
f7fd2959ac | ||
|
|
8652206aa4 | ||
|
|
7a5e03967d | ||
|
|
da19dfaadc | ||
|
|
60d960276d | ||
|
|
c8a6f9fbd8 | ||
|
|
2ef5a7baff | ||
|
|
d59c641b3d | ||
|
|
4ea80a2a09 | ||
|
|
45fe50f7f7 | ||
|
|
933a177c78 | ||
|
|
14fff570a4 | ||
|
|
3d0f3806e1 | ||
|
|
b597e2cc14 | ||
|
|
22dc85470a | ||
|
|
9a26dc7088 | ||
|
|
770a248d8c | ||
|
|
70cb1803b4 | ||
|
|
e9aa787ade | ||
|
|
01e2f1af79 | ||
|
|
1e67d51394 | ||
|
|
95936419c2 | ||
|
|
00ada93994 | ||
|
|
c57a924cc3 | ||
|
|
dc2f03dfad | ||
|
|
5c7a521705 | ||
|
|
074374b2af | ||
|
|
0a3aad38dc | ||
|
|
1730b5a2c4 | ||
|
|
12269c2c8e | ||
|
|
630dbefb7e | ||
|
|
00c8697c59 | ||
|
|
8df3d06598 | ||
|
|
f6ed6ee1f5 | ||
|
|
7e8d22ec6a | ||
|
|
d0595e679d | ||
|
|
4c7e713b6e | ||
|
|
bc0683281f | ||
|
|
b954345b38 | ||
|
|
ca4f78bd1c | ||
|
|
3dc8b156fe | ||
|
|
a818cd4dc4 | ||
|
|
2e9dcd165e | ||
|
|
53a52b8f06 | ||
|
|
c66facee92 | ||
|
|
3270e27c94 | ||
|
|
01cd125d4f | ||
|
|
5fcef4edf4 | ||
|
|
d33c79a525 | ||
|
|
d36c61d44e | ||
|
|
aed6081d37 | ||
|
|
66a4eef910 | ||
|
|
6da6fedc57 | ||
|
|
a7e095f3bf | ||
|
|
9a393d8e43 | ||
|
|
1a5c4f2404 | ||
|
|
13aaca03fd | ||
|
|
422aff1915 | ||
|
|
cac4e42364 | ||
|
|
c6d39884c8 | ||
|
|
4bb3678d4c | ||
|
|
5c2951de40 | ||
|
|
35d9ae8fec | ||
|
|
ff32632411 | ||
|
|
2c97964f0b | ||
|
|
fa748719ae | ||
|
|
1fb7b96c40 | ||
|
|
e13d04a5ea | ||
|
|
e3f5129746 | ||
|
|
24b054f55f | ||
|
|
8498d9f04f | ||
|
|
0fa58d8112 | ||
|
|
312b114ef7 | ||
|
|
a05b29e406 | ||
|
|
6e3e67467a | ||
|
|
09b5c8b72d | ||
|
|
5e71737240 | ||
|
|
eb726e80ab | ||
|
|
4f1272b01c | ||
|
|
30e3fcdcba | ||
|
|
b13fc58bad | ||
|
|
31b31ba345 | ||
|
|
f7449bfdcc | ||
|
|
cb0f05955f | ||
|
|
f1586d260a | ||
|
|
b268c43978 | ||
|
|
ed58a41042 | ||
|
|
7e4ba48de8 | ||
|
|
e9e16cc595 | ||
|
|
88d786b49f | ||
|
|
a24fea54ec | ||
|
|
8980b22fff | ||
|
|
389d923a08 | ||
|
|
714851c329 | ||
|
|
b27300e63d | ||
|
|
0869a82a31 | ||
|
|
507525262c | ||
|
|
a69170a4d2 | ||
|
|
26203bb744 | ||
|
|
21fb4702b4 | ||
|
|
aa8d1f9473 | ||
|
|
b6e9df9778 | ||
|
|
a7ed0cd62a | ||
|
|
e61fada0b2 | ||
|
|
3b0b1be197 | ||
|
|
616847c6b5 | ||
|
|
95b41131fc | ||
|
|
59a3150706 | ||
|
|
774b67b208 | ||
|
|
9a855cd30e | ||
|
|
9c6647aeae | ||
|
|
ecdec9083a | ||
|
|
1c0746c907 | ||
|
|
e7f8a5d1ff | ||
|
|
d927b43c01 | ||
|
|
1a36d69bd8 | ||
|
|
db90ce5975 | ||
|
|
b22d411481 | ||
|
|
d7fff65162 | ||
|
|
cf5d69c5ba | ||
|
|
53d26746c1 | ||
|
|
048433a135 | ||
|
|
31136b0523 | ||
|
|
caaf8547dc | ||
|
|
e92332859a | ||
|
|
55529b0b74 | ||
|
|
7682971b63 | ||
|
|
6aaf615c63 | ||
|
|
3512fc56c9 | ||
|
|
fda2ef1977 | ||
|
|
3784c2a6df | ||
|
|
04d74389ed | ||
|
|
1d338d0127 | ||
|
|
0fd6e34a8e | ||
|
|
9d89c79fb2 | ||
|
|
bfc31696ee | ||
|
|
8ebdc1030f | ||
|
|
8c3d706071 | ||
|
|
ee8912530d | ||
|
|
db91e0f1a4 | ||
|
|
5f2d0c089e | ||
|
|
75be372ec9 | ||
|
|
9ef81f7485 | ||
|
|
6a32790f28 | ||
|
|
9f5f7f9a64 | ||
|
|
06f6e3537a | ||
|
|
44f0295d0d | ||
|
|
19be2e93e2 | ||
|
|
5090a62594 | ||
|
|
dc2bbee481 | ||
|
|
8dc37f9bba | ||
|
|
14d93d2f6c | ||
|
|
768351fc15 | ||
|
|
c3dc8b7330 | ||
|
|
9fd2d3f75a | ||
|
|
760fa0c6a2 | ||
|
|
cf6a62266c | ||
|
|
f16fd36a24 | ||
|
|
c30e1b3f17 | ||
|
|
9985afde2d | ||
|
|
ccd6e95f70 | ||
|
|
fb904a5cc2 | ||
|
|
a255bbe2b8 | ||
|
|
a314878cc4 | ||
|
|
61b8b0a509 | ||
|
|
f144cd857e | ||
|
|
c922a1cd29 | ||
|
|
6cefe7b3c0 | ||
|
|
a3611b026c | ||
|
|
d33d3d9a4b | ||
|
|
ec68baa74e | ||
|
|
7216b0f5cb | ||
|
|
83b21bff7d | ||
|
|
cb52875651 | ||
|
|
89bcceaadc | ||
|
|
51dc454948 | ||
|
|
c7d57e24c3 | ||
|
|
54c702fb7f | ||
|
|
da6c9ebd1e | ||
|
|
f987e45c97 | ||
|
|
1f2ac2e1e7 | ||
|
|
b13c26b06a | ||
|
|
b5ace63438 | ||
|
|
15a81ee09f | ||
|
|
fecfdc101c | ||
|
|
4fdf3ef190 | ||
|
|
88f4ac2349 | ||
|
|
deaebd3333 | ||
|
|
6b0655aed3 | ||
|
|
2901345ea6 | ||
|
|
e51677b0f0 | ||
|
|
676166a160 | ||
|
|
761995b623 | ||
|
|
abc1b682d4 | ||
|
|
3b37cbb76c | ||
|
|
6c6229916e | ||
|
|
2aeb7b0710 | ||
|
|
a36c0629e9 | ||
|
|
5a9d3887a7 | ||
|
|
3fdd68b203 | ||
|
|
a1264deda0 | ||
|
|
f9fa5360d9 | ||
|
|
b4a1ec6e60 | ||
|
|
c30eb00c8d | ||
|
|
fc75405597 | ||
|
|
5726470881 | ||
|
|
6bbae1f7f0 | ||
|
|
ef5fe2fa91 | ||
|
|
585856ad89 | ||
|
|
8d1dbfe55d | ||
|
|
8a9c78662f | ||
|
|
8eb0029970 | ||
|
|
2dc20dd3fd | ||
|
|
2fefab5311 | ||
|
|
e7ab90a778 | ||
|
|
28226e4d03 | ||
|
|
e59ab99d45 | ||
|
|
3f1fa50608 | ||
|
|
71a37ace78 | ||
|
|
d4e16347b8 | ||
|
|
76c94ef0e1 | ||
|
|
db6351560a | ||
|
|
bd06a7ada7 | ||
|
|
42810a5e36 | ||
|
|
01e1dd027a |
5
.env
5
.env
@@ -10,6 +10,8 @@ LOGIN_URL=''
|
||||
LOGOUT_URL=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
ACCOUNT_SETTINGS_URL=''
|
||||
ACCOUNT_PROFILE_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEGMENT_KEY=''
|
||||
SITE_NAME=''
|
||||
@@ -27,3 +29,6 @@ APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
SEARCH_CATALOG_URL=''
|
||||
ENABLE_SKILLS_BUILDER_PROFILE=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
DISABLE_VISIBILITY_EDITING=''
|
||||
|
||||
@@ -3,7 +3,9 @@ PORT=1995
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:1995'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
ACCOUNT_SETTINGS_URL=http://localhost:1997
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ACCOUNT_PROFILE_URL=http://localhost:1995
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
@@ -28,3 +30,6 @@ APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
ENABLE_SKILLS_BUILDER_PROFILE=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
DISABLE_VISIBILITY_EDITING=''
|
||||
|
||||
@@ -5,6 +5,8 @@ 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'
|
||||
ACCOUNT_SETTINGS_URL='http://localhost:1997'
|
||||
ACCOUNT_PROFILE_URL='http://localhost:1995'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
@@ -23,3 +25,5 @@ LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'
|
||||
COLLECT_YEAR_OF_BIRTH=true
|
||||
APP_ID=''
|
||||
MFE_CONFIG_API_URL=''
|
||||
PARAGON_THEME_URLS={}
|
||||
DISABLE_VISIBILITY_EDITING=''
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
|
||||
7
.github/dependabot.yml
vendored
Normal file
7
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
24
.github/pull_request_template.md
vendored
Normal file
24
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
### Description
|
||||
|
||||
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
|
||||
|
||||
#### How Has This Been Tested?
|
||||
|
||||
Please describe in detail how you tested your changes.
|
||||
|
||||
#### Screenshots/sandbox (optional):
|
||||
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|
||||
|
||||
|Before|After|
|
||||
|-------|-----|
|
||||
| | |
|
||||
|
||||
#### Merge Checklist
|
||||
|
||||
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
|
||||
* [ ] Is there adequate test coverage for your changes?
|
||||
|
||||
#### Post-merge Checklist
|
||||
|
||||
* [ ] Deploy the changes to prod after verifying on stage or ask **@jacobo-dominguez-wgu** to do it.
|
||||
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.
|
||||
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
@@ -15,15 +15,15 @@ jobs:
|
||||
- test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Nodejs Env
|
||||
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VER }}
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make requirements
|
||||
- run: make test NPM_TESTS=build
|
||||
- run: make test NPM_TESTS=${{ matrix.npm-test }}
|
||||
- name: upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
- name: Coverage
|
||||
if: ${{ matrix.npm-test == 'test' }}
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
fail_ci_if_error: false
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
|
||||
2
.github/workflows/lockfileversion-check.yml
vendored
2
.github/workflows/lockfileversion-check.yml
vendored
@@ -10,4 +10,4 @@ on:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfile-check.yml@master
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ temp/babel-plugin-react-intl
|
||||
/temp
|
||||
/.vscode
|
||||
/module.config.js
|
||||
src/i18n/messages
|
||||
@@ -1,9 +0,0 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[o:open-edx:p:edx-platform:r:frontend-app-profile]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
|
||||
27
Makefile
27
Makefile
@@ -1,7 +1,3 @@
|
||||
export TRANSIFEX_RESOURCE = frontend-app-profile
|
||||
transifex_resource = frontend-app-profile
|
||||
transifex_langs = "ar,de,de_DE,es_419,fa_IR,fr,fr_CA,hi,it,it_IT,pt,pt_PT,ru,uk,zh_CN"
|
||||
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
@@ -39,35 +35,18 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
|
||||
|
||||
ifeq ($(OPENEDX_ATLAS_PULL),)
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
|
||||
else
|
||||
# Experimental: OEP-58 Pulls translations using atlas
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull --filter=$(transifex_langs) \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-app-profile/src/i18n/messages:frontend-app-profile
|
||||
|
||||
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-profile
|
||||
endif
|
||||
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-profile
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
|
||||
62
README.rst
62
README.rst
@@ -32,44 +32,58 @@ When a user views someone else's profile, they see all those fields that that us
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Installation
|
||||
============
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
Follow these steps to provision, run, and enable an instance of the
|
||||
Profile MFE for local development via the `devstack`_.
|
||||
The Tutor_ platform is a prerequisite for developing an MFE.
|
||||
Utilize `relevant tutor-mfe documentation`_ to guide you through
|
||||
the process of MFE development within the Tutor environment.
|
||||
|
||||
.. _devstack: https://github.com/openedx/devstack#getting-started
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
#. To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
* Start devstack
|
||||
* Log in (http://localhost:18000/login)
|
||||
|
||||
#. To run Profile, install requirements and start the development server by running:
|
||||
Cloning and Startup
|
||||
===================
|
||||
|
||||
.. code-block::
|
||||
1. Clone the repo:
|
||||
|
||||
1. Clone your new repo:
|
||||
``git clone https://github.com/openedx/frontend-app-profile.git``
|
||||
|
||||
``git clone https://github.com/openedx/frontend-app-profile.git``
|
||||
2. Use the version of node in the `.nvmrc` file.
|
||||
|
||||
2. Use node v18.x.
|
||||
The current version of the micro-frontend build scripts support the version of node found in `.nvmrc`.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
The current version of the micro-frontend build scripts support node 18.
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an .nvmrc file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
3. Install npm dependencies:
|
||||
|
||||
3. Install npm dependencies:
|
||||
``cd frontend-app-profile && npm ci``
|
||||
|
||||
``cd frontend-app-profile && npm ci``
|
||||
4. Mount the frontend-app-profile MFE in Tutor:
|
||||
|
||||
4. Start the dev server:
|
||||
``tutor mounts add <your-tutor-project-dir>/frontend-app-profile``
|
||||
5. Build the Docker image:
|
||||
|
||||
``npm start``
|
||||
The server will run on port 1995
|
||||
``tutor images build profile-dev``
|
||||
|
||||
Once the dev server is up, visit http://localhost:1995/u/staff.
|
||||
6. Launch the development server with Tutor:
|
||||
|
||||
``tutor dev start profile``
|
||||
|
||||
|
||||
The dev server is running at `http://localhost:1995/u/staff <http://localhost:1995/u/staff>`_.
|
||||
|
||||
`Tutor <https://github.com/overhangio/tutor>`_. If you start Tutor with ``tutor dev start profile``
|
||||
that should give you everything you need as a companion to this frontend.
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Configuration
|
||||
=============
|
||||
@@ -92,7 +106,7 @@ frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide. Please tag **@openedx/2u-aperture** on any PRs or issues.
|
||||
with as many details about the issue you are facing as you can provide. Please tag **@openedx/2u-infinity** on any PRs or issues.
|
||||
|
||||
https://github.com/openedx/frontend-app-profile/issues
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'Profile'
|
||||
description: 'This is a micro-frontend application responsible for the display and updating of user profiles.'
|
||||
name: 'frontend-app-profile'
|
||||
description: 'This is a micro-frontend application responsible for displaying and updating the user profiles.'
|
||||
links:
|
||||
- url: 'https://github.com/openedx/frontend-app-profile/blob/master/README.rst'
|
||||
title: 'Documentation'
|
||||
@@ -17,8 +17,9 @@ metadata:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
# This can be multiple comma-separated projects.
|
||||
openedx.org/add-to-projects: "openedx:23"
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: jacobo-dominguez-wgu
|
||||
type: 'service'
|
||||
lifecycle: 'production'
|
||||
owner: 2U-aperture
|
||||
# (Optional) An array of different components or resources.
|
||||
# (Optional) An array of different components or resources.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFiles: [
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
|
||||
|
||||
nick: prof
|
||||
oeps: {}
|
||||
openedx-release: {ref: master}
|
||||
25084
package-lock.json
generated
25084
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -14,6 +14,7 @@
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"dev": "PUBLIC_PATH=/profile/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
|
||||
"stubs": "pact-stub-service ./src/pacts/frontend-app-profile-edx-platform.json --port 18000"
|
||||
},
|
||||
@@ -29,51 +30,49 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/frontend-component-footer": "12.6.1",
|
||||
"@edx/frontend-component-header": "4.10.1",
|
||||
"@edx/frontend-platform": "5.6.1",
|
||||
"@edx/paragon": "^20.44.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "0.2.0",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-platform": "^8.3.1",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@fortawesome/fontawesome-svg-core": "6.7.2",
|
||||
"@fortawesome/free-brands-svg-icons": "6.7.2",
|
||||
"@fortawesome/free-regular-svg-icons": "6.7.2",
|
||||
"@fortawesome/free-solid-svg-icons": "6.7.2",
|
||||
"@fortawesome/react-fontawesome": "0.2.6",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@pact-foundation/pact": "^11.0.2",
|
||||
"classnames": "2.3.2",
|
||||
"core-js": "3.33.3",
|
||||
"@redux-devtools/extension": "3.3.0",
|
||||
"classnames": "2.5.1",
|
||||
"core-js": "3.48.0",
|
||||
"history": "5.3.0",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"lodash.get": "4.4.2",
|
||||
"lodash.pick": "4.4.0",
|
||||
"lodash.snakecase": "4.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.20.1",
|
||||
"react-router-dom": "6.20.1",
|
||||
"react-router": "6.30.3",
|
||||
"react-router-dom": "6.30.3",
|
||||
"redux": "4.2.1",
|
||||
"redux-devtools-extension": "2.13.9",
|
||||
"redux-logger": "3.0.6",
|
||||
"redux-saga": "1.2.3",
|
||||
"redux-saga": "1.4.2",
|
||||
"redux-thunk": "2.4.2",
|
||||
"regenerator-runtime": "0.14.1",
|
||||
"reselect": "4.1.8",
|
||||
"reselect": "5.1.1",
|
||||
"universal-cookie": "4.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "17.8.1",
|
||||
"@commitlint/config-angular": "17.8.1",
|
||||
"@commitlint/cli": "19.8.1",
|
||||
"@commitlint/config-angular": "19.8.1",
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-build": "13.0.14",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@testing-library/react": "12.1.5",
|
||||
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
|
||||
"enzyme": "3.11.0",
|
||||
"glob": "10.3.10",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"redux-mock-store": "1.5.4"
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"glob": "11.1.0",
|
||||
"redux-mock-store": "1.5.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { applyMiddleware, createStore, compose } from 'redux';
|
||||
import thunkMiddleware from 'redux-thunk';
|
||||
import { composeWithDevTools } from 'redux-devtools-extension';
|
||||
import { composeWithDevTools } from '@redux-devtools/extension';
|
||||
import { createLogger } from 'redux-logger';
|
||||
import createSagaMiddleware from 'redux-saga';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import { reducer as profilePage } from '../profile';
|
||||
import { reducer as profilePageReducer } from '../profile';
|
||||
|
||||
const createRootReducer = () => combineReducers({
|
||||
profilePage,
|
||||
profilePage: profilePageReducer,
|
||||
});
|
||||
|
||||
export default createRootReducer;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { all } from 'redux-saga/effects';
|
||||
|
||||
import { saga as profileSaga } from '../profile';
|
||||
|
||||
export default function* rootSaga() {
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const Head = ({ intl }) => (
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(messages['profile.page.title'], { siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
|
||||
</Helmet>
|
||||
);
|
||||
|
||||
Head.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
const Head = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Helmet>
|
||||
<title>
|
||||
{intl.formatMessage(messages['profile.page.title'], {
|
||||
siteName: getConfig().SITE_NAME,
|
||||
})}
|
||||
</title>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href={getConfig().FAVICON_URL}
|
||||
type="image/x-icon"
|
||||
/>
|
||||
</Helmet>
|
||||
);
|
||||
};
|
||||
|
||||
export default injectIntl(Head);
|
||||
export default Head;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { mount } from 'enzyme';
|
||||
import { render } from '@testing-library/react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import Head from './Head';
|
||||
|
||||
describe('Head', () => {
|
||||
const props = {};
|
||||
it('should match render title tag and favicon with the site configuration values', () => {
|
||||
mount(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
|
||||
render(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
|
||||
const helmet = Helmet.peek();
|
||||
expect(helmet.title).toEqual(`Profile | ${getConfig().SITE_NAME}`);
|
||||
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
|
||||
|
||||
@@ -1,44 +1 @@
|
||||
import { messages as headerMessages } from '@edx/frontend-component-header';
|
||||
import { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||
import { messages as paragonMessages } from '@edx/paragon';
|
||||
import arMessages from './messages/ar.json';
|
||||
import deMessages from './messages/de.json';
|
||||
import dedeCAMessages from './messages/de_DE.json';
|
||||
import es419Messages from './messages/es_419.json';
|
||||
import faIRMessages from './messages/fa_IR.json';
|
||||
import frCAMessages from './messages/fr_CA.json';
|
||||
import itMessages from './messages/it.json';
|
||||
import ititCAMessages from './messages/it_IT.json';
|
||||
import frMessages from './messages/fr.json';
|
||||
import hiMessages from './messages/hi.json';
|
||||
import ptMessages from './messages/pt.json';
|
||||
import ptptCAMessages from './messages/pt_PT.json';
|
||||
import ruMessages from './messages/ru.json';
|
||||
import ukMessages from './messages/uk.json';
|
||||
import zhcnMessages from './messages/zh_CN.json';
|
||||
// no need to import en messages-- they are in the defaultMessage field
|
||||
|
||||
const appMessages = {
|
||||
ar: arMessages,
|
||||
'es-419': es419Messages,
|
||||
'fa-ir': faIRMessages,
|
||||
fr: frMessages,
|
||||
'zh-cn': zhcnMessages,
|
||||
pt: ptMessages,
|
||||
it: itMessages,
|
||||
de: deMessages,
|
||||
hi: hiMessages,
|
||||
'fr-ca': frCAMessages,
|
||||
ru: ruMessages,
|
||||
uk: ukMessages,
|
||||
'de-de': dedeCAMessages,
|
||||
'it-it': ititCAMessages,
|
||||
'pt-pt': ptptCAMessages,
|
||||
};
|
||||
|
||||
export default [
|
||||
headerMessages,
|
||||
footerMessages,
|
||||
paragonMessages,
|
||||
appMessages,
|
||||
];
|
||||
export default [];
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "الملف الشخصي | {siteName}",
|
||||
"profile.age.details": "لمشاركة ملفك الشخصي مع بقية متعلمي {siteName}، يجب أن تؤكد أنك عمرك يفوق 13 عامًا.",
|
||||
"profile.age.set.date": "ضيط تاريخ ميلادك",
|
||||
"profile.datejoined.member.since": "عضو منذ {year}",
|
||||
"profile.notfound.message": "الصفحة التي تبحث عنها غير متوفرة أو هناك خطأ في العنوان. رجاءً تحقق من العنوان و حاول مجدّدًا.",
|
||||
"profile.viewMyRecords": "عرض سجلّاتي",
|
||||
"profile.loading": "يتم تحميل الملف الشخصي...",
|
||||
"profile.username.description": "معلومات ملفك الشخصي تظهر لك فقط. وحده اسم المستخدم الخاص بك يظهر للآخرين على {siteName}.",
|
||||
"profile.bio.empty": "إضافة نبذة قصيرة",
|
||||
"profile.bio.about.me": "نبذة عنّي",
|
||||
"profile.certificate.organization.label": "من طرف",
|
||||
"profile.certificate.completion.date.label": "صدرت بتاريخ {date}",
|
||||
"profile.no.certificates": "ليست لديك أي شهادات يعد.",
|
||||
"profile.certificates.my.certificates": "شهاداتي",
|
||||
"profile.certificates.view.certificate": "معاينة الشهادة",
|
||||
"profile.certificates.types.verified": "شهادة موثقة",
|
||||
"profile.certificates.types.professional": "شهادة مهنية",
|
||||
"profile.certificates.types.unknown": "شهادة",
|
||||
"profile.country.label": "الموقع",
|
||||
"profile.country.empty": "إضافة الموقع",
|
||||
"profile.education.empty": "إضافة المستوى التعليمي",
|
||||
"profile.education.education": "المستوى التعليمي",
|
||||
"profile.education.levels.p": "دكتوراه",
|
||||
"profile.education.levels.m": "ماجستير / ماستر أو شهادة مهنيّة",
|
||||
"profile.education.levels.b": "بكالوريوس / ليسانس",
|
||||
"profile.education.levels.a": "درجة الزمالة / دبلوم الدراسات الجامعية",
|
||||
"profile.education.levels.hs": "الثانوية العامة / البكالوريا",
|
||||
"profile.education.levels.jhs": "المدرسة الإعدادية / المتوسطة",
|
||||
"profile.education.levels.el": "المدرسة الابتدائية / الأساسية",
|
||||
"profile.education.levels.none": "دون تعليم رسمي",
|
||||
"profile.education.levels.o": "نوع آخر من التعليم",
|
||||
"profile.learningGoal.learningGoal": "هدف التعلم",
|
||||
"profile.learningGoal.options.start_career": "أريد أن أبدأ مسيرتي المهنية",
|
||||
"profile.learningGoal.options.advance_career": "أريد أن ارتقي في مسيرتي المهنية",
|
||||
"profile.learningGoal.options.learn_something_new": "أريد أن أتعلم شيئًا جديدًا",
|
||||
"profile.learningGoal.options.something_else": "شيء آخر",
|
||||
"profile.name.full.name": "الاسم الكامل",
|
||||
"profile.name.details": "هذا هو الاسم الذي يظهر في حسابك وفي شهاداتك",
|
||||
"profile.name.empty": "إضافة الاسم",
|
||||
"profile.preferredlanguage.empty": "إضافة اللغة",
|
||||
"profile.preferredlanguage.label": "لغة التحدّث الأساسية",
|
||||
"profile.profileavatar.upload-button": "تحميل صورة",
|
||||
"profile.profileavatar.remove.button": "حذف",
|
||||
"profile.image.alt.attribute": "صورة الملف الشخصي",
|
||||
"profile.profileavatar.change-button": "تغيير",
|
||||
"profile.sociallinks.add": "إضافة {network}",
|
||||
"profile.sociallinks.social.links": "روابط التواصل الاجتماعي",
|
||||
"profile.editbutton.edit": "تعديل",
|
||||
"profile.formcontrols.who.can.see": "من يستطيع رؤية هذا:",
|
||||
"profile.formcontrols.button.cancel": "إلغاء",
|
||||
"profile.formcontrols.button.save": "حفظ",
|
||||
"profile.formcontrols.button.saving": "الحفظ جارٍ",
|
||||
"profile.formcontrols.button.saved": "تم الحفظ",
|
||||
"profile.visibility.who.just.me": "أنا فقط",
|
||||
"profile.visibility.who.everyone": "جميع من على {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profile | {siteName}",
|
||||
"profile.age.details": "To share your profile with other {siteName} learners, you must confirm that you are over the age of 13.",
|
||||
"profile.age.set.date": "Set your date of birth",
|
||||
"profile.datejoined.member.since": "Member since {year}",
|
||||
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"profile.viewMyRecords": "View My Records",
|
||||
"profile.loading": "Profile loading...",
|
||||
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}.",
|
||||
"profile.bio.empty": "Add a short bio",
|
||||
"profile.bio.about.me": "About Me",
|
||||
"profile.certificate.organization.label": "From",
|
||||
"profile.certificate.completion.date.label": "Completed on {date}",
|
||||
"profile.no.certificates": "You don't have any certificates yet.",
|
||||
"profile.certificates.my.certificates": "My Certificates",
|
||||
"profile.certificates.view.certificate": "View Certificate",
|
||||
"profile.certificates.types.verified": "Verified Certificate",
|
||||
"profile.certificates.types.professional": "Professional Certificate",
|
||||
"profile.certificates.types.unknown": "Certificate",
|
||||
"profile.country.label": "Location",
|
||||
"profile.country.empty": "Add location",
|
||||
"profile.education.empty": "Add education",
|
||||
"profile.education.education": "Education",
|
||||
"profile.education.levels.p": "Doctorate",
|
||||
"profile.education.levels.m": "Master's or professional degree",
|
||||
"profile.education.levels.b": "Bachelor's Degree",
|
||||
"profile.education.levels.a": "Associate's degree",
|
||||
"profile.education.levels.hs": "Secondary/high school",
|
||||
"profile.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"profile.education.levels.el": "Elementary/primary school",
|
||||
"profile.education.levels.none": "No formal education",
|
||||
"profile.education.levels.o": "Other education",
|
||||
"profile.learningGoal.learningGoal": "Learning Goal",
|
||||
"profile.learningGoal.options.start_career": "I want to start my career",
|
||||
"profile.learningGoal.options.advance_career": "I want to advance my career",
|
||||
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
|
||||
"profile.learningGoal.options.something_else": "Something else",
|
||||
"profile.name.full.name": "Full Name",
|
||||
"profile.name.details": "This is the name that appears in your account and on your certificates.",
|
||||
"profile.name.empty": "Add name",
|
||||
"profile.preferredlanguage.empty": "Add language",
|
||||
"profile.preferredlanguage.label": "Primary Language Spoken",
|
||||
"profile.profileavatar.upload-button": "Upload Photo",
|
||||
"profile.profileavatar.remove.button": "Remove",
|
||||
"profile.image.alt.attribute": "profile avatar",
|
||||
"profile.profileavatar.change-button": "Change",
|
||||
"profile.sociallinks.add": "Add {network}",
|
||||
"profile.sociallinks.social.links": "Social Links",
|
||||
"profile.editbutton.edit": "Edit",
|
||||
"profile.formcontrols.who.can.see": "Who can see this:",
|
||||
"profile.formcontrols.button.cancel": "Cancel",
|
||||
"profile.formcontrols.button.save": "Save",
|
||||
"profile.formcontrols.button.saving": "Saving",
|
||||
"profile.formcontrols.button.saved": "Saved",
|
||||
"profile.visibility.who.just.me": "Just me",
|
||||
"profile.visibility.who.everyone": "Everyone on {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profil | {siteName}",
|
||||
"profile.age.details": "Um Ihr Profil mit anderen {siteName}-Lernern zu teilen, müssen Sie bestätigen, dass Sie über 13 Jahre alt sind.",
|
||||
"profile.age.set.date": "Legen Sie Ihr Geburtsdatum fest",
|
||||
"profile.datejoined.member.since": "Mitglied seit {year}",
|
||||
"profile.notfound.message": "Die gesuchte Seite ist nicht verfügbar oder es liegt ein Fehler in der URL vor. Bitte überprüfen Sie die URL und versuchen Sie es erneut.",
|
||||
"profile.viewMyRecords": "Meine Aufzeichnungen anzeigen",
|
||||
"profile.loading": "Profil lädt...",
|
||||
"profile.username.description": "Ihre Profilinformationen sind nur für Sie sichtbar. Nur Ihr Benutzername ist für andere auf {siteName} sichtbar.",
|
||||
"profile.bio.empty": "Fügen Sie Ihre Kurzbiografie hinzu",
|
||||
"profile.bio.about.me": "Über mich",
|
||||
"profile.certificate.organization.label": "Von",
|
||||
"profile.certificate.completion.date.label": "Abgeschlossen am {date}",
|
||||
"profile.no.certificates": "Sie haben bisher keine Zertifikate erhalten.",
|
||||
"profile.certificates.my.certificates": "Meine Zertifikate",
|
||||
"profile.certificates.view.certificate": "Zertifikat anschauen",
|
||||
"profile.certificates.types.verified": "Beglaubigtes Zertifikat ",
|
||||
"profile.certificates.types.professional": "Professional Certificate",
|
||||
"profile.certificates.types.unknown": "Zertifikat",
|
||||
"profile.country.label": "Ort",
|
||||
"profile.country.empty": "Standort hinzufügen",
|
||||
"profile.education.empty": "Ausbildung hinzufügen",
|
||||
"profile.education.education": "Bildung",
|
||||
"profile.education.levels.p": "Doktortitel",
|
||||
"profile.education.levels.m": "Master oder gleichwertiger akademischer Bildungsgrad",
|
||||
"profile.education.levels.b": "Bachelor",
|
||||
"profile.education.levels.a": "Allgemeine Hochschulreife oder gleichwertiger Abschluss",
|
||||
"profile.education.levels.hs": "Mittlere Reife",
|
||||
"profile.education.levels.jhs": "Hauptschule",
|
||||
"profile.education.levels.el": "Grundschule",
|
||||
"profile.education.levels.none": "Keinen Bildungsabschluss",
|
||||
"profile.education.levels.o": "Sonstige Bildung",
|
||||
"profile.learningGoal.learningGoal": "Lernziel",
|
||||
"profile.learningGoal.options.start_career": "Ich möchte meine Karriere starten",
|
||||
"profile.learningGoal.options.advance_career": "Ich möchte mich beruflich weiterentwickeln",
|
||||
"profile.learningGoal.options.learn_something_new": "Ich möchte etwas Neues lernen",
|
||||
"profile.learningGoal.options.something_else": "Etwas anderes",
|
||||
"profile.name.full.name": "Vollständiger Name",
|
||||
"profile.name.details": "Dies ist der Name, der in Ihrem Konto und auf Ihren Zertifikaten erscheint.",
|
||||
"profile.name.empty": "Name hinzufügen",
|
||||
"profile.preferredlanguage.empty": "Sprache hinzufügen",
|
||||
"profile.preferredlanguage.label": "Gesprochene Primärsprache ",
|
||||
"profile.profileavatar.upload-button": "Foto hochladen",
|
||||
"profile.profileavatar.remove.button": "Entfernen",
|
||||
"profile.image.alt.attribute": "Profil Avatar",
|
||||
"profile.profileavatar.change-button": "Ändern",
|
||||
"profile.sociallinks.add": "{network} hinzufügen",
|
||||
"profile.sociallinks.social.links": "Soziale Netzwerke",
|
||||
"profile.editbutton.edit": "Bearbeiten",
|
||||
"profile.formcontrols.who.can.see": "Wer kann das sehen:",
|
||||
"profile.formcontrols.button.cancel": "Abbrechen",
|
||||
"profile.formcontrols.button.save": "Speichern",
|
||||
"profile.formcontrols.button.saving": "Speichert",
|
||||
"profile.formcontrols.button.saved": "Gespeichert",
|
||||
"profile.visibility.who.just.me": "Nur ich",
|
||||
"profile.visibility.who.everyone": "Alle auf {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Perfil | {siteName}",
|
||||
"profile.age.details": "Para compartir el perfil con otros {siteName} estudiantes, debe confirmar que es mayor de 13 años.",
|
||||
"profile.age.set.date": "Establece tu fecha de nacimiento",
|
||||
"profile.datejoined.member.since": "Miembro desde {year}",
|
||||
"profile.notfound.message": "La página que estas buscando no está disponible o hay un error en la URL. Por favor, comprueba la URL y vuelve a intentarlo.",
|
||||
"profile.viewMyRecords": "Ver mis registros",
|
||||
"profile.loading": "Cargando perfil...",
|
||||
"profile.username.description": "La información del perfil solo la visualiza usted. Solo el nombre de usuario es visible para los demás en {siteName}.",
|
||||
"profile.bio.empty": "Añade una breve biografía",
|
||||
"profile.bio.about.me": "Sobre Mí",
|
||||
"profile.certificate.organization.label": "Desde",
|
||||
"profile.certificate.completion.date.label": "Completado el {date}",
|
||||
"profile.no.certificates": "Todavía no ha obtenido ningún certificado.",
|
||||
"profile.certificates.my.certificates": "Mis Certificados",
|
||||
"profile.certificates.view.certificate": "Ver Certificado",
|
||||
"profile.certificates.types.verified": "Certificado verificado",
|
||||
"profile.certificates.types.professional": "Certificado profesional",
|
||||
"profile.certificates.types.unknown": "Certificado",
|
||||
"profile.country.label": "Ubicación",
|
||||
"profile.country.empty": "Añade ubicación",
|
||||
"profile.education.empty": "Añade Educación",
|
||||
"profile.education.education": "Educación",
|
||||
"profile.education.levels.p": "Doctorado",
|
||||
"profile.education.levels.m": "Master o magíster",
|
||||
"profile.education.levels.b": "Pregrado o Licenciatura",
|
||||
"profile.education.levels.a": "Grado técnico - tecnológico",
|
||||
"profile.education.levels.hs": "Enseñanza secundaria",
|
||||
"profile.education.levels.jhs": "Formación media",
|
||||
"profile.education.levels.el": "Enseñanza primaria",
|
||||
"profile.education.levels.none": "Ninguna educación formal",
|
||||
"profile.education.levels.o": "Otra educación",
|
||||
"profile.learningGoal.learningGoal": "Objetivo de aprendizaje",
|
||||
"profile.learningGoal.options.start_career": "quiero empezar mi carrera",
|
||||
"profile.learningGoal.options.advance_career": "Quiero avanzar en mi carrera",
|
||||
"profile.learningGoal.options.learn_something_new": "quiero aprender algo nuevo",
|
||||
"profile.learningGoal.options.something_else": "Algo más",
|
||||
"profile.name.full.name": "Nombre completo",
|
||||
"profile.name.details": "Este es el nombre que aparecerá en tu cuenta y en tus certificados.",
|
||||
"profile.name.empty": "Añade nombre",
|
||||
"profile.preferredlanguage.empty": "Añadir idioma",
|
||||
"profile.preferredlanguage.label": "Idioma principal que hablas",
|
||||
"profile.profileavatar.upload-button": "Subir foto",
|
||||
"profile.profileavatar.remove.button": "Eliminar",
|
||||
"profile.image.alt.attribute": "avatar del perfil",
|
||||
"profile.profileavatar.change-button": "Cambiar",
|
||||
"profile.sociallinks.add": "Añade {network}",
|
||||
"profile.sociallinks.social.links": "Enlaces De Redes Sociales",
|
||||
"profile.editbutton.edit": "Editar",
|
||||
"profile.formcontrols.who.can.see": "Quién puede ver esto:",
|
||||
"profile.formcontrols.button.cancel": "Cancelar",
|
||||
"profile.formcontrols.button.save": "Guardar",
|
||||
"profile.formcontrols.button.saving": "Guardando",
|
||||
"profile.formcontrols.button.saved": "Guardado",
|
||||
"profile.visibility.who.just.me": "Solo yo",
|
||||
"profile.visibility.who.everyone": "Todos en {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "پرونده کاربری {siteName}",
|
||||
"profile.age.details": "برای اشتراکگذاری پرونده کاربری خود با سایر یادگیرندگان {siteName}، باید تأیید کنید که بیش از 13 سال سن دارید.",
|
||||
"profile.age.set.date": "تنظیم تاریخ تولد",
|
||||
"profile.datejoined.member.since": "عضو شده از {year}",
|
||||
"profile.notfound.message": "صفحه مورد نظر شما در دسترس نیست یا خطایی در نشانی آن وجود دارد. لطفاً نشانی اینترنتی را بررسی کرده و دوباره امتحان کنید.",
|
||||
"profile.viewMyRecords": "مشاهده سوابق من",
|
||||
"profile.loading": "در حال بارگذاری پرونده کاربری...",
|
||||
"profile.username.description": "اطلاعات پرونده کاربری فقط برای شما قابل مشاهده است. سایرین فقط نام کاربری شما را در {siteName} میتوانند ببینند.",
|
||||
"profile.bio.empty": "بیوگرافی کوتاهی اضافه کنید",
|
||||
"profile.bio.about.me": "درباره من",
|
||||
"profile.certificate.organization.label": "از",
|
||||
"profile.certificate.completion.date.label": "تکمیل شده در {date}",
|
||||
"profile.no.certificates": "شما هنوز هیچ گواهی ندارید.",
|
||||
"profile.certificates.my.certificates": "گواهیهای من",
|
||||
"profile.certificates.view.certificate": "نمایش گواهی",
|
||||
"profile.certificates.types.verified": "گواهی تأییدشده",
|
||||
"profile.certificates.types.professional": "گواهی حرفهای",
|
||||
"profile.certificates.types.unknown": "گواهی",
|
||||
"profile.country.label": "مکان",
|
||||
"profile.country.empty": "افزودن مکان",
|
||||
"profile.education.empty": "افزودن تحصیلات",
|
||||
"profile.education.education": "تحصیلات",
|
||||
"profile.education.levels.p": "درجه دکتری",
|
||||
"profile.education.levels.m": "کارشناسی ارشد یا مدرک حرفهای",
|
||||
"profile.education.levels.b": "مدرک کارشناسی",
|
||||
"profile.education.levels.a": "مدرک کاردانی",
|
||||
"profile.education.levels.hs": "متوسطه/دبیرستان",
|
||||
"profile.education.levels.jhs": "مدرسه متوسطه دوره اول/ راهنمایی",
|
||||
"profile.education.levels.el": "مدرسه ابتدایی",
|
||||
"profile.education.levels.none": "بدون تحصیلات رسمی",
|
||||
"profile.education.levels.o": "تحصیلات متفرقه",
|
||||
"profile.learningGoal.learningGoal": "هدف یادگیری",
|
||||
"profile.learningGoal.options.start_career": "من می خواهم کارم را شروع کنم",
|
||||
"profile.learningGoal.options.advance_career": "من می خواهم حرفه ام را ارتقا دهم",
|
||||
"profile.learningGoal.options.learn_something_new": "میخواهم چیز جدیدی یاد بگیرم",
|
||||
"profile.learningGoal.options.something_else": "یک چیز دیگر",
|
||||
"profile.name.full.name": "نام و نام خانوادگی",
|
||||
"profile.name.details": "این همان نامی است که در حساب کاربری و گواهیهای شما درج میشود.",
|
||||
"profile.name.empty": "افزودن نام",
|
||||
"profile.preferredlanguage.empty": "افزودن زبان",
|
||||
"profile.preferredlanguage.label": "زبان اصلی صحبت شده",
|
||||
"profile.profileavatar.upload-button": "بارگذاری عکس",
|
||||
"profile.profileavatar.remove.button": "حذف",
|
||||
"profile.image.alt.attribute": "چهرک پرونده کاربری",
|
||||
"profile.profileavatar.change-button": "تغییر",
|
||||
"profile.sociallinks.add": "افزودن {network}",
|
||||
"profile.sociallinks.social.links": "پیوندهای رسانه اجتماعی",
|
||||
"profile.editbutton.edit": " ویرایش",
|
||||
"profile.formcontrols.who.can.see": "کسانی که میتوانند این را ببینند:",
|
||||
"profile.formcontrols.button.cancel": "لغو",
|
||||
"profile.formcontrols.button.save": "ذخیره",
|
||||
"profile.formcontrols.button.saving": "در حال ذخیره",
|
||||
"profile.formcontrols.button.saved": "ذخیره شد",
|
||||
"profile.visibility.who.just.me": "فقط من",
|
||||
"profile.visibility.who.everyone": "هرکسی در {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profile | {siteName}",
|
||||
"profile.age.details": "Pour partager votre profil avec d'autres étudiants {siteName}, vous devez confirmer que vous avez plus de 13 ans.",
|
||||
"profile.age.set.date": "Définissez votre date de naissance",
|
||||
"profile.datejoined.member.since": "Membre depuis {year}",
|
||||
"profile.notfound.message": "La page que vous recherchez n'est pas disponible ou il y a une erreur dans l'URL. Veuillez vérifier l'URL et réessayer.",
|
||||
"profile.viewMyRecords": "Voir mes succès",
|
||||
"profile.loading": "Chargement du profil....",
|
||||
"profile.username.description": "Les informations de votre profil ne sont visibles que par vous. Seul votre nom d'utilisateur est visible par les autres sur {siteName}.",
|
||||
"profile.bio.empty": "Ajouter une courte biographie",
|
||||
"profile.bio.about.me": "À propos de moi",
|
||||
"profile.certificate.organization.label": "De",
|
||||
"profile.certificate.completion.date.label": "Terminé le {date}",
|
||||
"profile.no.certificates": "Vous n'avez pas encore de certificats.",
|
||||
"profile.certificates.my.certificates": "Mes certificats",
|
||||
"profile.certificates.view.certificate": "Voir le certificat",
|
||||
"profile.certificates.types.verified": "Certificat vérifié",
|
||||
"profile.certificates.types.professional": "Certificat professionnel",
|
||||
"profile.certificates.types.unknown": "Certificat",
|
||||
"profile.country.label": "Localisation",
|
||||
"profile.country.empty": "Ajouter localisation",
|
||||
"profile.education.empty": "Ajouter une éducation",
|
||||
"profile.education.education": "Education",
|
||||
"profile.education.levels.p": "Doctorat",
|
||||
"profile.education.levels.m": "Master ou diplôme professionnel",
|
||||
"profile.education.levels.b": "Diplôme de licence",
|
||||
"profile.education.levels.a": "Grade de l'associé",
|
||||
"profile.education.levels.hs": "Lycée / enseignement secondaire",
|
||||
"profile.education.levels.jhs": "Collège / enseignement secondaire inférieur",
|
||||
"profile.education.levels.el": "Enseignement primaire",
|
||||
"profile.education.levels.none": "Sans diplôme",
|
||||
"profile.education.levels.o": "Autre niveau d'étude",
|
||||
"profile.learningGoal.learningGoal": "Learning Goal",
|
||||
"profile.learningGoal.options.start_career": "I want to start my career",
|
||||
"profile.learningGoal.options.advance_career": "I want to advance my career",
|
||||
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
|
||||
"profile.learningGoal.options.something_else": "Something else",
|
||||
"profile.name.full.name": "Nom complet",
|
||||
"profile.name.details": "C'est le nom qui apparaît dans votre compte et sur vos certificats.",
|
||||
"profile.name.empty": "Ajouter un nom",
|
||||
"profile.preferredlanguage.empty": "Ajouter une langue",
|
||||
"profile.preferredlanguage.label": "Langue principale parlée",
|
||||
"profile.profileavatar.upload-button": "Envoyer la photo",
|
||||
"profile.profileavatar.remove.button": "Supprimer",
|
||||
"profile.image.alt.attribute": "Profil avatar",
|
||||
"profile.profileavatar.change-button": "Modifier",
|
||||
"profile.sociallinks.add": "Ajouter {network}",
|
||||
"profile.sociallinks.social.links": "Liens vers les réseaux sociaux",
|
||||
"profile.editbutton.edit": "Modifier",
|
||||
"profile.formcontrols.who.can.see": "Qui peut voir ça :",
|
||||
"profile.formcontrols.button.cancel": "Annuler",
|
||||
"profile.formcontrols.button.save": "Enregistrer",
|
||||
"profile.formcontrols.button.saving": "Enregistrement",
|
||||
"profile.formcontrols.button.saved": "Enregistré",
|
||||
"profile.visibility.who.just.me": "Juste moi",
|
||||
"profile.visibility.who.everyone": "Tout le monde sur {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profil | {siteName}",
|
||||
"profile.age.details": "Pour partager votre profil avec d'autres apprenants {siteName}, vous devez confirmer que vous avez plus de 13 ans.",
|
||||
"profile.age.set.date": "Entrez votre date de naissance",
|
||||
"profile.datejoined.member.since": "Membre depuis {year}",
|
||||
"profile.notfound.message": "La page que vous recherchez n'est pas disponible ou il y a une erreur dans l'URL. Veuillez vérifier l'URL et réessayer.",
|
||||
"profile.viewMyRecords": "Afficher mes dossiers",
|
||||
"profile.loading": "Chargement du profil...",
|
||||
"profile.username.description": "Les informations de votre profil ne sont visibles que par vous. Seul votre nom d'utilisateur est visible par les autres sur {siteName}.",
|
||||
"profile.bio.empty": "Ajouter une courte biographie",
|
||||
"profile.bio.about.me": "À propos de moi",
|
||||
"profile.certificate.organization.label": "De",
|
||||
"profile.certificate.completion.date.label": "Terminé le {date}",
|
||||
"profile.no.certificates": "Vous n'avez pas encore d'attestation.",
|
||||
"profile.certificates.my.certificates": "Mes Attestations",
|
||||
"profile.certificates.view.certificate": "Voir votre attestation",
|
||||
"profile.certificates.types.verified": "Attestation vérifiée",
|
||||
"profile.certificates.types.professional": "Attestation professionnelle",
|
||||
"profile.certificates.types.unknown": "Attestation",
|
||||
"profile.country.label": "Adresse",
|
||||
"profile.country.empty": "Ajouter un emplacement",
|
||||
"profile.education.empty": "Ajouter formation",
|
||||
"profile.education.education": "Formation",
|
||||
"profile.education.levels.p": "Doctorat",
|
||||
"profile.education.levels.m": "Maîtrise ou diplôme professionnel",
|
||||
"profile.education.levels.b": "Diplôme de baccalauréat",
|
||||
"profile.education.levels.a": "Diplôme d'associé",
|
||||
"profile.education.levels.hs": "Lycée / enseignement secondaire",
|
||||
"profile.education.levels.jhs": "Collège / enseignement secondaire inférieur",
|
||||
"profile.education.levels.el": "Enseignement primaire",
|
||||
"profile.education.levels.none": "Sans formation formelle",
|
||||
"profile.education.levels.o": "Autre niveau de formation",
|
||||
"profile.learningGoal.learningGoal": "Objectif d'apprentissage",
|
||||
"profile.learningGoal.options.start_career": "Je veux commencer ma carrière",
|
||||
"profile.learningGoal.options.advance_career": "Je veux faire progresser ma carrière",
|
||||
"profile.learningGoal.options.learn_something_new": "Je veux apprendre quelque chose de nouveau",
|
||||
"profile.learningGoal.options.something_else": "Autre chose",
|
||||
"profile.name.full.name": "Nom complet",
|
||||
"profile.name.details": "C'est le nom qui apparaît dans votre compte et sur vos attestations.",
|
||||
"profile.name.empty": "Ajouter un nom",
|
||||
"profile.preferredlanguage.empty": "Ajouter une langue",
|
||||
"profile.preferredlanguage.label": "Langue principale parlée",
|
||||
"profile.profileavatar.upload-button": "Téléverser une photo",
|
||||
"profile.profileavatar.remove.button": "Retirer",
|
||||
"profile.image.alt.attribute": "avatar de profil",
|
||||
"profile.profileavatar.change-button": "Modifier",
|
||||
"profile.sociallinks.add": "Ajouter {network}",
|
||||
"profile.sociallinks.social.links": "Liens vers les réseaux sociaux",
|
||||
"profile.editbutton.edit": "Éditer",
|
||||
"profile.formcontrols.who.can.see": "Qui peut voir ça :",
|
||||
"profile.formcontrols.button.cancel": "Annuler",
|
||||
"profile.formcontrols.button.save": "Sauvegarder",
|
||||
"profile.formcontrols.button.saving": "Sauvegarde en cours",
|
||||
"profile.formcontrols.button.saved": "Sauvegardé",
|
||||
"profile.visibility.who.just.me": "Juste moi",
|
||||
"profile.visibility.who.everyone": "Tout le monde sur {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profile | {siteName}",
|
||||
"profile.age.details": "To share your profile with other {siteName} learners, you must confirm that you are over the age of 13.",
|
||||
"profile.age.set.date": "Set your date of birth",
|
||||
"profile.datejoined.member.since": "Member since {year}",
|
||||
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"profile.viewMyRecords": "View My Records",
|
||||
"profile.loading": "Profile loading...",
|
||||
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}.",
|
||||
"profile.bio.empty": "Add a short bio",
|
||||
"profile.bio.about.me": "About Me",
|
||||
"profile.certificate.organization.label": "From",
|
||||
"profile.certificate.completion.date.label": "Completed on {date}",
|
||||
"profile.no.certificates": "You don't have any certificates yet.",
|
||||
"profile.certificates.my.certificates": "My Certificates",
|
||||
"profile.certificates.view.certificate": "View Certificate",
|
||||
"profile.certificates.types.verified": "Verified Certificate",
|
||||
"profile.certificates.types.professional": "Professional Certificate",
|
||||
"profile.certificates.types.unknown": "Certificate",
|
||||
"profile.country.label": "Location",
|
||||
"profile.country.empty": "Add location",
|
||||
"profile.education.empty": "Add education",
|
||||
"profile.education.education": "Education",
|
||||
"profile.education.levels.p": "Doctorate",
|
||||
"profile.education.levels.m": "Master's or professional degree",
|
||||
"profile.education.levels.b": "Bachelor's Degree",
|
||||
"profile.education.levels.a": "Associate's degree",
|
||||
"profile.education.levels.hs": "Secondary/high school",
|
||||
"profile.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"profile.education.levels.el": "Elementary/primary school",
|
||||
"profile.education.levels.none": "No formal education",
|
||||
"profile.education.levels.o": "Other education",
|
||||
"profile.learningGoal.learningGoal": "Learning Goal",
|
||||
"profile.learningGoal.options.start_career": "I want to start my career",
|
||||
"profile.learningGoal.options.advance_career": "I want to advance my career",
|
||||
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
|
||||
"profile.learningGoal.options.something_else": "Something else",
|
||||
"profile.name.full.name": "Full Name",
|
||||
"profile.name.details": "This is the name that appears in your account and on your certificates.",
|
||||
"profile.name.empty": "Add name",
|
||||
"profile.preferredlanguage.empty": "Add language",
|
||||
"profile.preferredlanguage.label": "Primary Language Spoken",
|
||||
"profile.profileavatar.upload-button": "Upload Photo",
|
||||
"profile.profileavatar.remove.button": "Remove",
|
||||
"profile.image.alt.attribute": "profile avatar",
|
||||
"profile.profileavatar.change-button": "Change",
|
||||
"profile.sociallinks.add": "Add {network}",
|
||||
"profile.sociallinks.social.links": "Social Links",
|
||||
"profile.editbutton.edit": "Edit",
|
||||
"profile.formcontrols.who.can.see": "Who can see this:",
|
||||
"profile.formcontrols.button.cancel": "Cancel",
|
||||
"profile.formcontrols.button.save": "Save",
|
||||
"profile.formcontrols.button.saving": "Saving",
|
||||
"profile.formcontrols.button.saved": "Saved",
|
||||
"profile.visibility.who.just.me": "Just me",
|
||||
"profile.visibility.who.everyone": "Everyone on {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profile | {siteName}",
|
||||
"profile.age.details": "To share your profile with other {siteName} learners, you must confirm that you are over the age of 13.",
|
||||
"profile.age.set.date": "Set your date of birth",
|
||||
"profile.datejoined.member.since": "Member since {year}",
|
||||
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"profile.viewMyRecords": "View My Records",
|
||||
"profile.loading": "Profile loading...",
|
||||
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}.",
|
||||
"profile.bio.empty": "Add a short bio",
|
||||
"profile.bio.about.me": "About Me",
|
||||
"profile.certificate.organization.label": "From",
|
||||
"profile.certificate.completion.date.label": "Completed on {date}",
|
||||
"profile.no.certificates": "You don't have any certificates yet.",
|
||||
"profile.certificates.my.certificates": "My Certificates",
|
||||
"profile.certificates.view.certificate": "View Certificate",
|
||||
"profile.certificates.types.verified": "Verified Certificate",
|
||||
"profile.certificates.types.professional": "Professional Certificate",
|
||||
"profile.certificates.types.unknown": "Certificate",
|
||||
"profile.country.label": "Location",
|
||||
"profile.country.empty": "Add location",
|
||||
"profile.education.empty": "Add education",
|
||||
"profile.education.education": "Education",
|
||||
"profile.education.levels.p": "Doctorate",
|
||||
"profile.education.levels.m": "Master's or professional degree",
|
||||
"profile.education.levels.b": "Bachelor's Degree",
|
||||
"profile.education.levels.a": "Associate's degree",
|
||||
"profile.education.levels.hs": "Secondary/high school",
|
||||
"profile.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"profile.education.levels.el": "Elementary/primary school",
|
||||
"profile.education.levels.none": "No formal education",
|
||||
"profile.education.levels.o": "Other education",
|
||||
"profile.learningGoal.learningGoal": "Learning Goal",
|
||||
"profile.learningGoal.options.start_career": "I want to start my career",
|
||||
"profile.learningGoal.options.advance_career": "I want to advance my career",
|
||||
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
|
||||
"profile.learningGoal.options.something_else": "Something else",
|
||||
"profile.name.full.name": "Full Name",
|
||||
"profile.name.details": "This is the name that appears in your account and on your certificates.",
|
||||
"profile.name.empty": "Add name",
|
||||
"profile.preferredlanguage.empty": "Add language",
|
||||
"profile.preferredlanguage.label": "Primary Language Spoken",
|
||||
"profile.profileavatar.upload-button": "Upload Photo",
|
||||
"profile.profileavatar.remove.button": "Remove",
|
||||
"profile.image.alt.attribute": "profile avatar",
|
||||
"profile.profileavatar.change-button": "Change",
|
||||
"profile.sociallinks.add": "Add {network}",
|
||||
"profile.sociallinks.social.links": "Social Links",
|
||||
"profile.editbutton.edit": "Edit",
|
||||
"profile.formcontrols.who.can.see": "Who can see this:",
|
||||
"profile.formcontrols.button.cancel": "Cancel",
|
||||
"profile.formcontrols.button.save": "Save",
|
||||
"profile.formcontrols.button.saving": "Saving",
|
||||
"profile.formcontrols.button.saved": "Saved",
|
||||
"profile.visibility.who.just.me": "Just me",
|
||||
"profile.visibility.who.everyone": "Everyone on {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profilo | {siteName}",
|
||||
"profile.age.details": "Per condividere il tuo profilo con altri studenti di {siteName}, devi confermare di avere più di 13 anni.",
|
||||
"profile.age.set.date": "Imposta la data di nascita ",
|
||||
"profile.datejoined.member.since": "Membro da {year}",
|
||||
"profile.notfound.message": "La pagina ricercata non è disponibile oppure è presente un errore nell'URL. Controllare l'URL e riprovare. ",
|
||||
"profile.viewMyRecords": "Visualizza record personali ",
|
||||
"profile.loading": "Caricamento del profilo... ",
|
||||
"profile.username.description": "Le informazioni del tuo profilo sono visibili solo a te. Solo il tuo nome utente è visibile agli altri su {siteName}.",
|
||||
"profile.bio.empty": "Aggiungi una breve biografia ",
|
||||
"profile.bio.about.me": "Su di me",
|
||||
"profile.certificate.organization.label": "Da ",
|
||||
"profile.certificate.completion.date.label": "Completato il {date}",
|
||||
"profile.no.certificates": "Non si dispone ancora di alcun certificato. ",
|
||||
"profile.certificates.my.certificates": "Certificati personali ",
|
||||
"profile.certificates.view.certificate": "Visualizza il certificato",
|
||||
"profile.certificates.types.verified": "Certificato Verificato",
|
||||
"profile.certificates.types.professional": "Certificato professionale ",
|
||||
"profile.certificates.types.unknown": "Certificato ",
|
||||
"profile.country.label": "Posizione",
|
||||
"profile.country.empty": "Aggiungi posizione ",
|
||||
"profile.education.empty": "Aggiungi titolo di studio ",
|
||||
"profile.education.education": "Educazione",
|
||||
"profile.education.levels.p": "Dottorato",
|
||||
"profile.education.levels.m": "Laurea magistrale o titolo accademico professionale",
|
||||
"profile.education.levels.b": "Laurea di primo livello ",
|
||||
"profile.education.levels.a": "Diploma Professionale",
|
||||
"profile.education.levels.hs": "Scuole superiori/liceo",
|
||||
"profile.education.levels.jhs": "Scuole Medie",
|
||||
"profile.education.levels.el": "Scuola Primaria/Elementare",
|
||||
"profile.education.levels.none": "Nessun livello educativo formale",
|
||||
"profile.education.levels.o": "Altro livello educativo",
|
||||
"profile.learningGoal.learningGoal": "Obiettivo di apprendimento",
|
||||
"profile.learningGoal.options.start_career": "Voglio iniziare il mio percorso",
|
||||
"profile.learningGoal.options.advance_career": "Voglio avanzare nel mio percorso",
|
||||
"profile.learningGoal.options.learn_something_new": "Voglio imparare qualcosa di nuovo",
|
||||
"profile.learningGoal.options.something_else": "Qualcos'altro",
|
||||
"profile.name.full.name": "Nome e Cognome",
|
||||
"profile.name.details": "Questo è il nome visualizzato nel proprio account e nei propri certificati. ",
|
||||
"profile.name.empty": "Aggiungi nome ",
|
||||
"profile.preferredlanguage.empty": "Aggiungi lingua",
|
||||
"profile.preferredlanguage.label": "Lingua principale ",
|
||||
"profile.profileavatar.upload-button": "Carica foto",
|
||||
"profile.profileavatar.remove.button": "Rimuovi",
|
||||
"profile.image.alt.attribute": "avatar del profilo ",
|
||||
"profile.profileavatar.change-button": "Cambia",
|
||||
"profile.sociallinks.add": "Aggiungi {network}",
|
||||
"profile.sociallinks.social.links": "Link social ",
|
||||
"profile.editbutton.edit": "Modifica",
|
||||
"profile.formcontrols.who.can.see": "Chi può visualizzare: ",
|
||||
"profile.formcontrols.button.cancel": "Annulla",
|
||||
"profile.formcontrols.button.save": "Salva",
|
||||
"profile.formcontrols.button.saving": "Salvataggio in corso",
|
||||
"profile.formcontrols.button.saved": "Salvato",
|
||||
"profile.visibility.who.just.me": "Solo io ",
|
||||
"profile.visibility.who.everyone": "Tutti su {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profile | {siteName}",
|
||||
"profile.age.details": "To share your profile with other {siteName} learners, you must confirm that you are over the age of 13.",
|
||||
"profile.age.set.date": "Set your date of birth",
|
||||
"profile.datejoined.member.since": "Member since {year}",
|
||||
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"profile.viewMyRecords": "View My Records",
|
||||
"profile.loading": "Profile loading...",
|
||||
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}.",
|
||||
"profile.bio.empty": "Add a short bio",
|
||||
"profile.bio.about.me": "About Me",
|
||||
"profile.certificate.organization.label": "From",
|
||||
"profile.certificate.completion.date.label": "Completed on {date}",
|
||||
"profile.no.certificates": "You don't have any certificates yet.",
|
||||
"profile.certificates.my.certificates": "My Certificates",
|
||||
"profile.certificates.view.certificate": "View Certificate",
|
||||
"profile.certificates.types.verified": "Verified Certificate",
|
||||
"profile.certificates.types.professional": "Professional Certificate",
|
||||
"profile.certificates.types.unknown": "Certificate",
|
||||
"profile.country.label": "Location",
|
||||
"profile.country.empty": "Add location",
|
||||
"profile.education.empty": "Add education",
|
||||
"profile.education.education": "Education",
|
||||
"profile.education.levels.p": "Doctorate",
|
||||
"profile.education.levels.m": "Master's or professional degree",
|
||||
"profile.education.levels.b": "Bachelor's Degree",
|
||||
"profile.education.levels.a": "Associate's degree",
|
||||
"profile.education.levels.hs": "Secondary/high school",
|
||||
"profile.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"profile.education.levels.el": "Elementary/primary school",
|
||||
"profile.education.levels.none": "No formal education",
|
||||
"profile.education.levels.o": "Other education",
|
||||
"profile.learningGoal.learningGoal": "Learning Goal",
|
||||
"profile.learningGoal.options.start_career": "I want to start my career",
|
||||
"profile.learningGoal.options.advance_career": "I want to advance my career",
|
||||
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
|
||||
"profile.learningGoal.options.something_else": "Something else",
|
||||
"profile.name.full.name": "Full Name",
|
||||
"profile.name.details": "This is the name that appears in your account and on your certificates.",
|
||||
"profile.name.empty": "Add name",
|
||||
"profile.preferredlanguage.empty": "Add language",
|
||||
"profile.preferredlanguage.label": "Primary Language Spoken",
|
||||
"profile.profileavatar.upload-button": "Upload Photo",
|
||||
"profile.profileavatar.remove.button": "Remove",
|
||||
"profile.image.alt.attribute": "profile avatar",
|
||||
"profile.profileavatar.change-button": "Change",
|
||||
"profile.sociallinks.add": "Add {network}",
|
||||
"profile.sociallinks.social.links": "Social Links",
|
||||
"profile.editbutton.edit": "Edit",
|
||||
"profile.formcontrols.who.can.see": "Who can see this:",
|
||||
"profile.formcontrols.button.cancel": "Cancel",
|
||||
"profile.formcontrols.button.save": "Save",
|
||||
"profile.formcontrols.button.saving": "Saving",
|
||||
"profile.formcontrols.button.saved": "Saved",
|
||||
"profile.visibility.who.just.me": "Just me",
|
||||
"profile.visibility.who.everyone": "Everyone on {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Perfil | {siteName}",
|
||||
"profile.age.details": "Para partilhar o seu perfil com outros estudantes da plataforma {siteName}, tem de confirmar que tem mais de 13 anos de idade.",
|
||||
"profile.age.set.date": "Indique a sua data de nascimento",
|
||||
"profile.datejoined.member.since": "Utilizador desde {year}",
|
||||
"profile.notfound.message": "A página que procura não está disponível ou há um erro no URL. Por favor, verifique o URL e tente novamente.",
|
||||
"profile.viewMyRecords": "Ver os Meus Registos",
|
||||
"profile.loading": "A carregar perfil...",
|
||||
"profile.username.description": "As informações do seu perfil só são visíveis para si. Apenas o seu nome de utilizador é visível para outros em {siteName}.",
|
||||
"profile.bio.empty": "Adicionar uma breve biografia",
|
||||
"profile.bio.about.me": "Sobre Mim",
|
||||
"profile.certificate.organization.label": "De",
|
||||
"profile.certificate.completion.date.label": "Concluído a {date}",
|
||||
"profile.no.certificates": "Ainda não tem certificados.",
|
||||
"profile.certificates.my.certificates": "Os Meus Certificados",
|
||||
"profile.certificates.view.certificate": "Ver Certificado",
|
||||
"profile.certificates.types.verified": "Certificado Validado",
|
||||
"profile.certificates.types.professional": "Certificado Profissional",
|
||||
"profile.certificates.types.unknown": "Certificado",
|
||||
"profile.country.label": "Localização",
|
||||
"profile.country.empty": "Adicionar localização",
|
||||
"profile.education.empty": "Adicionar grau de escolaridade",
|
||||
"profile.education.education": "Educação",
|
||||
"profile.education.levels.p": "Doutoramento",
|
||||
"profile.education.levels.m": "Mestrado ou Grau Profissional",
|
||||
"profile.education.levels.b": "Licenciatura",
|
||||
"profile.education.levels.a": "Pós-graduação",
|
||||
"profile.education.levels.hs": "Secundário",
|
||||
"profile.education.levels.jhs": "2ªciclo/3ºciclo",
|
||||
"profile.education.levels.el": "Primária",
|
||||
"profile.education.levels.none": "Sem estudos",
|
||||
"profile.education.levels.o": "Outra formação",
|
||||
"profile.learningGoal.learningGoal": "Objectivo de aprendizagem",
|
||||
"profile.learningGoal.options.start_career": "Quero começar a minha carreira",
|
||||
"profile.learningGoal.options.advance_career": "Quero progredir na minha carreira",
|
||||
"profile.learningGoal.options.learn_something_new": "Quero aprender algo novo",
|
||||
"profile.learningGoal.options.something_else": "Outra coisa",
|
||||
"profile.name.full.name": "Nome Completo",
|
||||
"profile.name.details": "Este é o nome que aparece na sua conta e nos seus certificados.",
|
||||
"profile.name.empty": "Adicionar nome",
|
||||
"profile.preferredlanguage.empty": "Adicionar idioma",
|
||||
"profile.preferredlanguage.label": "Língua Materna",
|
||||
"profile.profileavatar.upload-button": "Carregar Fotografia",
|
||||
"profile.profileavatar.remove.button": "Eliminar",
|
||||
"profile.image.alt.attribute": "Icon de perfil",
|
||||
"profile.profileavatar.change-button": "Alterar",
|
||||
"profile.sociallinks.add": "Adicionar {network}",
|
||||
"profile.sociallinks.social.links": "Links de Redes Sociais",
|
||||
"profile.editbutton.edit": "Editar",
|
||||
"profile.formcontrols.who.can.see": "Quem pode ver isto:",
|
||||
"profile.formcontrols.button.cancel": "Cancelar",
|
||||
"profile.formcontrols.button.save": "Guardar",
|
||||
"profile.formcontrols.button.saving": "A Guardar",
|
||||
"profile.formcontrols.button.saved": "Guardado",
|
||||
"profile.visibility.who.just.me": "Apenas eu",
|
||||
"profile.visibility.who.everyone": "Toda a gente em {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profile | {siteName}",
|
||||
"profile.age.details": "To share your profile with other {siteName} learners, you must confirm that you are over the age of 13.",
|
||||
"profile.age.set.date": "Set your date of birth",
|
||||
"profile.datejoined.member.since": "Member since {year}",
|
||||
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"profile.viewMyRecords": "View My Records",
|
||||
"profile.loading": "Profile loading...",
|
||||
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}.",
|
||||
"profile.bio.empty": "Add a short bio",
|
||||
"profile.bio.about.me": "About Me",
|
||||
"profile.certificate.organization.label": "From",
|
||||
"profile.certificate.completion.date.label": "Completed on {date}",
|
||||
"profile.no.certificates": "You don't have any certificates yet.",
|
||||
"profile.certificates.my.certificates": "My Certificates",
|
||||
"profile.certificates.view.certificate": "View Certificate",
|
||||
"profile.certificates.types.verified": "Verified Certificate",
|
||||
"profile.certificates.types.professional": "Professional Certificate",
|
||||
"profile.certificates.types.unknown": "Certificate",
|
||||
"profile.country.label": "Location",
|
||||
"profile.country.empty": "Add location",
|
||||
"profile.education.empty": "Add education",
|
||||
"profile.education.education": "Education",
|
||||
"profile.education.levels.p": "Doctorate",
|
||||
"profile.education.levels.m": "Master's or professional degree",
|
||||
"profile.education.levels.b": "Bachelor's Degree",
|
||||
"profile.education.levels.a": "Associate's degree",
|
||||
"profile.education.levels.hs": "Secondary/high school",
|
||||
"profile.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"profile.education.levels.el": "Elementary/primary school",
|
||||
"profile.education.levels.none": "No formal education",
|
||||
"profile.education.levels.o": "Other education",
|
||||
"profile.learningGoal.learningGoal": "Learning Goal",
|
||||
"profile.learningGoal.options.start_career": "I want to start my career",
|
||||
"profile.learningGoal.options.advance_career": "I want to advance my career",
|
||||
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
|
||||
"profile.learningGoal.options.something_else": "Something else",
|
||||
"profile.name.full.name": "Full Name",
|
||||
"profile.name.details": "This is the name that appears in your account and on your certificates.",
|
||||
"profile.name.empty": "Add name",
|
||||
"profile.preferredlanguage.empty": "Add language",
|
||||
"profile.preferredlanguage.label": "Primary Language Spoken",
|
||||
"profile.profileavatar.upload-button": "Upload Photo",
|
||||
"profile.profileavatar.remove.button": "Remove",
|
||||
"profile.image.alt.attribute": "profile avatar",
|
||||
"profile.profileavatar.change-button": "Change",
|
||||
"profile.sociallinks.add": "Add {network}",
|
||||
"profile.sociallinks.social.links": "Social Links",
|
||||
"profile.editbutton.edit": "Edit",
|
||||
"profile.formcontrols.who.can.see": "Who can see this:",
|
||||
"profile.formcontrols.button.cancel": "Cancel",
|
||||
"profile.formcontrols.button.save": "Save",
|
||||
"profile.formcontrols.button.saving": "Saving",
|
||||
"profile.formcontrols.button.saved": "Saved",
|
||||
"profile.visibility.who.just.me": "Just me",
|
||||
"profile.visibility.who.everyone": "Everyone on {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "Profile | {siteName}",
|
||||
"profile.age.details": "To share your profile with other {siteName} learners, you must confirm that you are over the age of 13.",
|
||||
"profile.age.set.date": "Set your date of birth",
|
||||
"profile.datejoined.member.since": "Member since {year}",
|
||||
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
|
||||
"profile.viewMyRecords": "View My Records",
|
||||
"profile.loading": "Profile loading...",
|
||||
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}.",
|
||||
"profile.bio.empty": "Add a short bio",
|
||||
"profile.bio.about.me": "About Me",
|
||||
"profile.certificate.organization.label": "From",
|
||||
"profile.certificate.completion.date.label": "Completed on {date}",
|
||||
"profile.no.certificates": "You don't have any certificates yet.",
|
||||
"profile.certificates.my.certificates": "Мої сертифікати",
|
||||
"profile.certificates.view.certificate": "View Certificate",
|
||||
"profile.certificates.types.verified": "Verified Certificate",
|
||||
"profile.certificates.types.professional": "Professional Certificate",
|
||||
"profile.certificates.types.unknown": "Certificate",
|
||||
"profile.country.label": "Location",
|
||||
"profile.country.empty": "Add location",
|
||||
"profile.education.empty": "Add education",
|
||||
"profile.education.education": "Education",
|
||||
"profile.education.levels.p": "Doctorate",
|
||||
"profile.education.levels.m": "Master's or professional degree",
|
||||
"profile.education.levels.b": "Bachelor's Degree",
|
||||
"profile.education.levels.a": "Associate's degree",
|
||||
"profile.education.levels.hs": "Secondary/high school",
|
||||
"profile.education.levels.jhs": "Junior secondary/junior high/middle school",
|
||||
"profile.education.levels.el": "Elementary/primary school",
|
||||
"profile.education.levels.none": "No formal education",
|
||||
"profile.education.levels.o": "Other education",
|
||||
"profile.learningGoal.learningGoal": "Learning Goal",
|
||||
"profile.learningGoal.options.start_career": "I want to start my career",
|
||||
"profile.learningGoal.options.advance_career": "I want to advance my career",
|
||||
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
|
||||
"profile.learningGoal.options.something_else": "Something else",
|
||||
"profile.name.full.name": "Full Name",
|
||||
"profile.name.details": "This is the name that appears in your account and on your certificates.",
|
||||
"profile.name.empty": "Add name",
|
||||
"profile.preferredlanguage.empty": "Add language",
|
||||
"profile.preferredlanguage.label": "Primary Language Spoken",
|
||||
"profile.profileavatar.upload-button": "Upload Photo",
|
||||
"profile.profileavatar.remove.button": "Remove",
|
||||
"profile.image.alt.attribute": "profile avatar",
|
||||
"profile.profileavatar.change-button": "Change",
|
||||
"profile.sociallinks.add": "Add {network}",
|
||||
"profile.sociallinks.social.links": "Social Links",
|
||||
"profile.editbutton.edit": "Edit",
|
||||
"profile.formcontrols.who.can.see": "Who can see this:",
|
||||
"profile.formcontrols.button.cancel": "Cancel",
|
||||
"profile.formcontrols.button.save": "Save",
|
||||
"profile.formcontrols.button.saving": "Saving",
|
||||
"profile.formcontrols.button.saved": "Saved",
|
||||
"profile.visibility.who.just.me": "Just me",
|
||||
"profile.visibility.who.everyone": "Everyone on {siteName}"
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
{
|
||||
"profile.page.title": "简介 | {siteName}",
|
||||
"profile.age.details": "要与其他 {siteName} 学员分享您的个人资料,您必须确认您已年满 13 岁。",
|
||||
"profile.age.set.date": "设置您的出生日期",
|
||||
"profile.datejoined.member.since": "会员自{year}",
|
||||
"profile.notfound.message": "您访问的地址不存在或有误。请检查URL后重新尝试访问。",
|
||||
"profile.viewMyRecords": "查看我的记录",
|
||||
"profile.loading": "简介文件加载中......",
|
||||
"profile.username.description": "您的个人资料信息仅对您可见。 {siteName} 上的其他人只能看到您的用户名。",
|
||||
"profile.bio.empty": "添加个人简历",
|
||||
"profile.bio.about.me": "个人资料",
|
||||
"profile.certificate.organization.label": "从",
|
||||
"profile.certificate.completion.date.label": "在{date}前完成",
|
||||
"profile.no.certificates": "你还没有任何证书。",
|
||||
"profile.certificates.my.certificates": "我的证书",
|
||||
"profile.certificates.view.certificate": "查看证书",
|
||||
"profile.certificates.types.verified": "认证证书",
|
||||
"profile.certificates.types.professional": "专业证书",
|
||||
"profile.certificates.types.unknown": "证书",
|
||||
"profile.country.label": "位置",
|
||||
"profile.country.empty": "添加地址",
|
||||
"profile.education.empty": "添加教育程度",
|
||||
"profile.education.education": "教育程度",
|
||||
"profile.education.levels.p": "博士",
|
||||
"profile.education.levels.m": "硕士",
|
||||
"profile.education.levels.b": "学士学位",
|
||||
"profile.education.levels.a": "副学士学位",
|
||||
"profile.education.levels.hs": "高中",
|
||||
"profile.education.levels.jhs": "初中",
|
||||
"profile.education.levels.el": "小学",
|
||||
"profile.education.levels.none": "未受正规教育",
|
||||
"profile.education.levels.o": "其他教育程度",
|
||||
"profile.learningGoal.learningGoal": "学习目标",
|
||||
"profile.learningGoal.options.start_career": "我想开始我的职业生涯",
|
||||
"profile.learningGoal.options.advance_career": "我想提升我的事业",
|
||||
"profile.learningGoal.options.learn_something_new": "我想学习新东西",
|
||||
"profile.learningGoal.options.something_else": "别的东西",
|
||||
"profile.name.full.name": "全名",
|
||||
"profile.name.details": "用于显示在账户和证书上的姓名。",
|
||||
"profile.name.empty": "添加名字",
|
||||
"profile.preferredlanguage.empty": "添加语言",
|
||||
"profile.preferredlanguage.label": "主要使用语言",
|
||||
"profile.profileavatar.upload-button": "上传照片",
|
||||
"profile.profileavatar.remove.button": "移除",
|
||||
"profile.image.alt.attribute": "个人头像",
|
||||
"profile.profileavatar.change-button": "更改",
|
||||
"profile.sociallinks.add": "添加{network}",
|
||||
"profile.sociallinks.social.links": "社交链接",
|
||||
"profile.editbutton.edit": "编辑",
|
||||
"profile.formcontrols.who.can.see": "对谁可见:",
|
||||
"profile.formcontrols.button.cancel": "取消",
|
||||
"profile.formcontrols.button.save": "保存",
|
||||
"profile.formcontrols.button.saving": "正在保存",
|
||||
"profile.formcontrols.button.saved": "已保存",
|
||||
"profile.visibility.who.just.me": "仅自己",
|
||||
"profile.visibility.who.everyone": "{siteName} 上的每个人"
|
||||
}
|
||||
@@ -14,35 +14,38 @@ import {
|
||||
} from '@edx/frontend-platform/react';
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
// eslint-disable-next-line import/no-unresolved
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import Header from '@edx/frontend-component-header';
|
||||
import Footer from '@edx/frontend-component-footer';
|
||||
import { FooterSlot } from '@edx/frontend-component-footer';
|
||||
|
||||
import messages from './i18n';
|
||||
import configureStore from './data/configureStore';
|
||||
|
||||
import './index.scss';
|
||||
import Head from './head/Head';
|
||||
|
||||
import AppRoutes from './routes/AppRoutes';
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
import './index.scss';
|
||||
|
||||
const rootNode = createRoot(document.getElementById('root'));
|
||||
subscribe(APP_READY, async () => {
|
||||
rootNode.render(
|
||||
<AppProvider store={configureStore()}>
|
||||
<Head />
|
||||
<Header />
|
||||
<main id="main">
|
||||
<AppRoutes />
|
||||
</main>
|
||||
<Footer />
|
||||
<FooterSlot />
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
});
|
||||
|
||||
subscribe(APP_INIT_ERROR, (error) => {
|
||||
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
|
||||
rootNode.render(<ErrorPage message={error.message} />, document.getElementById('root'));
|
||||
});
|
||||
|
||||
initialize({
|
||||
@@ -53,6 +56,7 @@ initialize({
|
||||
mergeConfig({
|
||||
COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH,
|
||||
ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE,
|
||||
DISABLE_VISIBILITY_EDITING: process.env.DISABLE_VISIBILITY_EDITING,
|
||||
}, 'App loadConfig override handler');
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
@import "~@edx/brand/paragon/fonts";
|
||||
@import "~@edx/brand/paragon/variables";
|
||||
@import "~@edx/paragon/scss/core/core";
|
||||
@import "~@edx/brand/paragon/overrides";
|
||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
||||
|
||||
@import "~@edx/frontend-component-header/dist/index";
|
||||
@import "~@edx/frontend-component-footer/dist/footer";
|
||||
|
||||
@import './profile/index';
|
||||
@import 'profile/index';
|
||||
|
||||
@@ -36,7 +36,11 @@
|
||||
"dateJoined": "2017-06-07T00:44:23Z",
|
||||
"email": "staff@example.com",
|
||||
"isActive": true,
|
||||
"languageProficiencies": [],
|
||||
"levelOfEducation": null,
|
||||
"name": "Lemon Seltzer",
|
||||
"profileImage": {},
|
||||
"socialLinks": [],
|
||||
"username": "staff",
|
||||
"yearOfBirth": 1901
|
||||
},
|
||||
|
||||
97
src/plugin-slots/AdditionalProfileFieldsSlot/README.md
Normal file
97
src/plugin-slots/AdditionalProfileFieldsSlot/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Additional Profile Fields
|
||||
|
||||
### Slot ID: `org.openedx.frontend.profile.additional_profile_fields.v1`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the additional profile fields in the profile page.
|
||||
|
||||
## Example
|
||||
The following `env.config.jsx` will extend the default fields with a additional custom fields through a simple example component.
|
||||
|
||||

|
||||
|
||||
### Using the Additional Fields Component
|
||||
Create a file named `env.config.jsx` at the MFE root with this:
|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
import Example from './src/plugin-slots/AdditionalProfileFieldsSlot/example';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
'org.openedx.frontend.profile.additional_profile_fields.v1': {
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'additional_profile_fields',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: Example,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Plugin Props
|
||||
|
||||
When implementing a plugin for this slot, the following props are available:
|
||||
|
||||
### `updateUserProfile`
|
||||
- **Type**: Function
|
||||
- **Description**: A function for updating the user's profile with new field values. This handles the API call to persist changes to the backend.
|
||||
- **Usage**: Pass an object containing the field updates to be saved to the user's profile. The function automatically handles the persistence and UI updates.
|
||||
|
||||
#### Example
|
||||
```javascript
|
||||
updateUserProfile({ extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
|
||||
```
|
||||
|
||||
### `profileFieldValues`
|
||||
- **Type**: Array of Objects
|
||||
- **Description**: Contains the current values of all additional profile fields as an array of objects. Each object has a `fieldName` property (string) and a `fieldValue` property (which can be string, boolean, number, or other data types depending on the field type).
|
||||
- **Usage**: Access specific field values by finding the object with the matching `fieldName` and reading its `fieldValue` property. Use array methods like `find()` to locate specific fields.
|
||||
|
||||
#### Example
|
||||
```javascript
|
||||
// Finding a specific field value
|
||||
const nifField = profileFieldValues.find(field => field.fieldName === 'nif');
|
||||
const nifValue = nifField ? nifField.fieldValue : null;
|
||||
|
||||
// Example data structure:
|
||||
[
|
||||
{
|
||||
"fieldName": "favorite_color",
|
||||
"fieldValue": "red"
|
||||
},
|
||||
{
|
||||
"fieldName": "employment_situation",
|
||||
"fieldValue": "Unemployed"
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### `profileFieldErrors`
|
||||
- **Type**: Object
|
||||
- **Description**: Contains validation errors for profile fields. Each key corresponds to a field name, and the value is the error message.
|
||||
- **Usage**: Check for field-specific errors to display validation feedback to users.
|
||||
|
||||
### `formComponents`
|
||||
- **Type**: Object
|
||||
- **Description**: Provides access to reusable form components that are consistent with the rest of the profile page styling and behavior. These components follow the platform's design system and include proper validation and accessibility features.
|
||||
- **Usage**: Use these components in your custom fields implementation to maintain UI consistency. Available components include `SwitchContent` for managing different UI states, `EmptyContent` for empty states, and `EditableItemHeader` for consistent headers.
|
||||
|
||||
### `refreshUserProfile`
|
||||
- **Type**: Function
|
||||
- **Description**: A function that triggers a refresh of the user's profile data. This can be used after updating profile fields to ensure the UI reflects the latest data from the server.
|
||||
- **Usage**: Call this function with the username parameter when you need to manually reload the user profile information. Note that `updateUserProfile` typically handles data refresh automatically.
|
||||
|
||||
#### Example
|
||||
```javascript
|
||||
refreshUserProfile(username);
|
||||
```
|
||||
129
src/plugin-slots/AdditionalProfileFieldsSlot/example/index.jsx
Normal file
129
src/plugin-slots/AdditionalProfileFieldsSlot/example/index.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
|
||||
/**
|
||||
* Straightforward example of how you could use the pluginProps provided by
|
||||
* the AdditionalProfileFieldsSlot to create a custom profile field.
|
||||
*
|
||||
* Here you can set a 'favorite_color' field with radio buttons and
|
||||
* save it to the user's profile, especifically to their `meta` in
|
||||
* the user's model. For more information, see the documentation:
|
||||
*
|
||||
* https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/user_api/README.rst#persisting-optional-user-metadata
|
||||
*/
|
||||
const Example = ({
|
||||
updateUserProfile,
|
||||
profileFieldValues,
|
||||
profileFieldErrors,
|
||||
formComponents: { SwitchContent, EditableItemHeader, EmptyContent } = {},
|
||||
}) => {
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
const [formMode, setFormMode] = useState('editable');
|
||||
|
||||
// Get current favorite color from profileFieldValues
|
||||
const currentColorField = profileFieldValues?.find(field => field.fieldName === 'favorite_color');
|
||||
const currentColor = currentColorField ? currentColorField.fieldValue : '';
|
||||
|
||||
const [value, setValue] = useState(currentColor);
|
||||
const handleChange = e => setValue(e.target.value);
|
||||
|
||||
// Get any validation errors for the favorite_color field
|
||||
const colorFieldError = profileFieldErrors?.favorite_color;
|
||||
|
||||
useEffect(() => {
|
||||
if (!value) { setFormMode('empty'); }
|
||||
if (colorFieldError) {
|
||||
setFormMode('editing');
|
||||
}
|
||||
}, [colorFieldError, value]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
try {
|
||||
updateUserProfile(authenticatedUser.username, { extendedProfile: [{ fieldName: 'favorite_color', fieldValue: value }] });
|
||||
setFormMode('editable');
|
||||
} catch (error) {
|
||||
setFormMode('editing');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-accent-500 p-3 mt-5">
|
||||
<h3 className="h3">Example Additional Profile Fields Slot</h3>
|
||||
|
||||
<SwitchContent
|
||||
className="pt-40px"
|
||||
expression={formMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<>
|
||||
<label className="edit-section-header" htmlFor="favorite_color">
|
||||
Favorite Color
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="favorite_color"
|
||||
name="favorite_color"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button type="button" className="mt-2" onClick={handleSubmit}>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<div className="row m-0 pb-1.5 align-items-center">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0">
|
||||
Favorite Color
|
||||
</p>
|
||||
</div>
|
||||
<EditableItemHeader
|
||||
content={value}
|
||||
showEditButton
|
||||
onClickEdit={() => setFormMode('editing')}
|
||||
showVisibility={false}
|
||||
visibility="private"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<div className="row m-0 pb-1.5 align-items-center">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0">
|
||||
Favorite Color
|
||||
</p>
|
||||
</div>
|
||||
<EmptyContent onClick={() => setFormMode('editing')}>
|
||||
<p className="mb-0">Click to add your favorite color</p>
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Example.propTypes = {
|
||||
updateUserProfile: PropTypes.func.isRequired,
|
||||
profileFieldValues: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
fieldName: PropTypes.string.isRequired,
|
||||
fieldValue: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
]).isRequired,
|
||||
}),
|
||||
),
|
||||
profileFieldErrors: PropTypes.objectOf(PropTypes.string),
|
||||
formComponents: PropTypes.shape({
|
||||
SwitchContent: PropTypes.elementType.isRequired,
|
||||
}),
|
||||
};
|
||||
|
||||
export default Example;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
37
src/plugin-slots/AdditionalProfileFieldsSlot/index.jsx
Normal file
37
src/plugin-slots/AdditionalProfileFieldsSlot/index.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { PluginSlot } from '@openedx/frontend-plugin-framework';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { patchProfile } from '../../profile/data/services';
|
||||
import { fetchProfile } from '../../profile/data/actions';
|
||||
|
||||
import SwitchContent from '../../profile/forms/elements/SwitchContent';
|
||||
import EmptyContent from '../../profile/forms/elements/EmptyContent';
|
||||
import EditableItemHeader from '../../profile/forms/elements/EditableItemHeader';
|
||||
|
||||
const AdditionalProfileFieldsSlot = () => {
|
||||
const dispatch = useDispatch();
|
||||
const extendedProfileValues = useSelector((state) => state.profilePage.account.extendedProfile);
|
||||
const errors = useSelector((state) => state.profilePage.errors);
|
||||
|
||||
const pluginProps = {
|
||||
refreshUserProfile: useCallback((username) => dispatch(fetchProfile(username)), [dispatch]),
|
||||
updateUserProfile: patchProfile,
|
||||
profileFieldValues: extendedProfileValues,
|
||||
profileFieldErrors: errors,
|
||||
formComponents: {
|
||||
SwitchContent,
|
||||
EmptyContent,
|
||||
EditableItemHeader,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<PluginSlot
|
||||
id="org.openedx.frontend.profile.additional_profile_fields.v1"
|
||||
pluginProps={pluginProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdditionalProfileFieldsSlot;
|
||||
53
src/plugin-slots/FooterSlot/README.md
Normal file
53
src/plugin-slots/FooterSlot/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Footer Slot
|
||||
|
||||
### Slot ID: `org.openedx.frontend.layout.footer.v1`
|
||||
|
||||
### Slot ID Aliases
|
||||
* `footer_slot`
|
||||
|
||||
## Description
|
||||
|
||||
This slot is used to replace/modify/hide the footer.
|
||||
|
||||
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
|
||||
|
||||
## Example
|
||||
|
||||
The following `env.config.jsx` will replace the default footer.
|
||||
|
||||

|
||||
|
||||
with a simple custom footer
|
||||
|
||||

|
||||
|
||||
```jsx
|
||||
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
const config = {
|
||||
pluginSlots: {
|
||||
'org.openedx.frontend.layout.footer.v1': {
|
||||
plugins: [
|
||||
{
|
||||
// Hide the default footer
|
||||
op: PLUGIN_OPERATIONS.Hide,
|
||||
widgetId: 'default_contents',
|
||||
},
|
||||
{
|
||||
// Insert a custom footer
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'custom_footer',
|
||||
type: DIRECT_PLUGIN,
|
||||
RenderWidget: () => (
|
||||
<h1 style={{textAlign: 'center'}}>🦶</h1>
|
||||
),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default config;
|
||||
```
|
||||
BIN
src/plugin-slots/FooterSlot/images/custom_footer.png
Normal file
BIN
src/plugin-slots/FooterSlot/images/custom_footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
src/plugin-slots/FooterSlot/images/default_footer.png
Normal file
BIN
src/plugin-slots/FooterSlot/images/default_footer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
3
src/plugin-slots/README.md
Normal file
3
src/plugin-slots/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# `frontend-app-profile` Plugin Slots
|
||||
|
||||
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
const AgeMessage = ({ accountSettingsUrl }) => (
|
||||
<Alert
|
||||
variant="info"
|
||||
dismissible={false}
|
||||
show
|
||||
>
|
||||
<Alert.Heading id="profile.age.headline">
|
||||
Your profile cannot be shared.
|
||||
</Alert.Heading>
|
||||
<FormattedMessage
|
||||
id="profile.age.details"
|
||||
defaultMessage="To share your profile with other {siteName} learners, you must confirm that you are over the age of 13."
|
||||
description="Error message"
|
||||
tagName="p"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
<Alert.Link href={accountSettingsUrl}>
|
||||
<FormattedMessage
|
||||
id="profile.age.set.date"
|
||||
defaultMessage="Set your date of birth"
|
||||
description="Label on a link to set birthday"
|
||||
/>
|
||||
</Alert.Link>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
AgeMessage.propTypes = {
|
||||
accountSettingsUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AgeMessage;
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const Banner = () => <div className="profile-page-bg-banner bg-primary d-none d-md-block p-relative" />;
|
||||
|
||||
export default Banner;
|
||||
146
src/profile/CertificateCard.jsx
Normal file
146
src/profile/CertificateCard.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedDate, FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@openedx/paragon';
|
||||
import get from 'lodash.get';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import professionalCertificateSVG from './assets/professional-certificate.svg';
|
||||
import verifiedCertificateSVG from './assets/verified-certificate.svg';
|
||||
import messages from './Certificates.messages';
|
||||
import { useIsOnMobileScreen } from './data/hooks';
|
||||
|
||||
const CertificateCard = ({
|
||||
certificateType,
|
||||
courseDisplayName,
|
||||
courseOrganization,
|
||||
modifiedDate,
|
||||
downloadUrl,
|
||||
courseId,
|
||||
uuid,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const certificateIllustration = {
|
||||
professional: professionalCertificateSVG,
|
||||
'no-id-professional': professionalCertificateSVG,
|
||||
verified: verifiedCertificateSVG,
|
||||
honor: null,
|
||||
audit: null,
|
||||
}[certificateType] || null;
|
||||
|
||||
const isMobileView = useIsOnMobileScreen();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${modifiedDate}-${courseId}`}
|
||||
className="col-auto d-flex align-items-center p-0"
|
||||
>
|
||||
<div className="col certificate p-4 border-light-400 bg-light-200 w-100 h-100">
|
||||
<div
|
||||
className="certificate-type-illustration"
|
||||
style={{ backgroundImage: `url(${certificateIllustration})` }}
|
||||
/>
|
||||
<div className={classNames(
|
||||
'd-flex flex-column position-relative p-0',
|
||||
{ 'max-width-304px': isMobileView },
|
||||
{ 'width-314px': !isMobileView },
|
||||
)}
|
||||
>
|
||||
<div className="w-100 color-black">
|
||||
<p className={classNames([
|
||||
'mb-0 font-weight-normal',
|
||||
isMobileView ? 'x-small' : 'small',
|
||||
])}
|
||||
>
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.certificates.types.${certificateType}`,
|
||||
messages['profile.certificates.types.unknown'],
|
||||
))}
|
||||
</p>
|
||||
<p className={classNames([
|
||||
'm-0 color-black',
|
||||
isMobileView ? 'h5' : 'h4',
|
||||
])}
|
||||
>
|
||||
{courseDisplayName}
|
||||
</p>
|
||||
<p className={classNames([
|
||||
'mb-0',
|
||||
isMobileView ? 'x-small' : 'small',
|
||||
])}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="profile.certificate.organization.label"
|
||||
defaultMessage="From"
|
||||
/>
|
||||
</p>
|
||||
<h5 className="mb-0 color-black">{courseOrganization}</h5>
|
||||
<p className={classNames([
|
||||
'mb-0',
|
||||
isMobileView ? 'x-small' : 'small',
|
||||
])}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="profile.certificate.completion.date.label"
|
||||
defaultMessage="Completed on {date}"
|
||||
values={{
|
||||
date: <FormattedDate value={new Date(modifiedDate)} />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-3">
|
||||
<Hyperlink
|
||||
destination={downloadUrl}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
className={classNames(
|
||||
'btn btn-primary font-weight-normal px-4 py-10px',
|
||||
{ 'btn-sm': isMobileView },
|
||||
)}
|
||||
>
|
||||
{intl.formatMessage(messages['profile.certificates.view.certificate'])}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
<p
|
||||
className={classNames([
|
||||
'mb-0 pt-3',
|
||||
isMobileView ? 'x-small' : 'small',
|
||||
])}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="profile.certificate.uuid"
|
||||
defaultMessage="Credential ID {certificate_uuid}"
|
||||
values={{
|
||||
certificate_uuid: uuid,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CertificateCard.propTypes = {
|
||||
certificateType: PropTypes.string,
|
||||
courseDisplayName: PropTypes.string,
|
||||
courseOrganization: PropTypes.string,
|
||||
modifiedDate: PropTypes.string,
|
||||
downloadUrl: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
uuid: PropTypes.string,
|
||||
};
|
||||
|
||||
CertificateCard.defaultProps = {
|
||||
certificateType: 'unknown',
|
||||
courseDisplayName: '',
|
||||
courseOrganization: '',
|
||||
modifiedDate: '',
|
||||
downloadUrl: '',
|
||||
uuid: '',
|
||||
};
|
||||
|
||||
export default CertificateCard;
|
||||
92
src/profile/Certificates.jsx
Normal file
92
src/profile/Certificates.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { connect } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import CertificateCard from './CertificateCard';
|
||||
import { certificatesSelector } from './data/selectors';
|
||||
import { useIsOnTabletScreen } from './data/hooks';
|
||||
|
||||
const Certificates = ({ certificates }) => {
|
||||
const isTabletView = useIsOnTabletScreen();
|
||||
return (
|
||||
<div>
|
||||
<div className="col justify-content-start align-items-start g-5rem p-0">
|
||||
<div className="col align-self-stretch height-42px justify-content-start align-items-start p-0">
|
||||
<p className="font-weight-bold text-primary-500 m-0 h2">
|
||||
<FormattedMessage
|
||||
id="profile.your.certificates"
|
||||
defaultMessage="Your certificates"
|
||||
description="heading for the certificates section"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div className="col justify-content-start align-items-start pt-2 p-0">
|
||||
<p className="font-weight-normal text-gray-800 m-0 p-0 p">
|
||||
<FormattedMessage
|
||||
id="profile.certificates.description"
|
||||
defaultMessage="Your learner records information is only visible to you. Only your username and profile image are visible to others on {siteName}."
|
||||
description="description of the certificates section"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{certificates?.length > 0 ? (
|
||||
<div className="col">
|
||||
<div className={classNames(
|
||||
'row align-items-center pt-5 g-3rem',
|
||||
{ 'justify-content-center': isTabletView },
|
||||
)}
|
||||
>
|
||||
{certificates.map(certificate => (
|
||||
<CertificateCard
|
||||
key={certificate.courseId}
|
||||
certificateType={certificate.certificateType}
|
||||
courseDisplayName={certificate.courseDisplayName}
|
||||
courseOrganization={certificate.courseOrganization}
|
||||
modifiedDate={certificate.modifiedDate}
|
||||
downloadUrl={certificate.downloadUrl}
|
||||
courseId={certificate.courseId}
|
||||
uuid={certificate.uuid}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-5">
|
||||
<FormattedMessage
|
||||
id="profile.no.certificates"
|
||||
defaultMessage="You don't have any certificates yet."
|
||||
description="displays when user has no course completion certificates"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Certificates.propTypes = {
|
||||
certificates: PropTypes.arrayOf(PropTypes.shape({
|
||||
certificateType: PropTypes.string,
|
||||
courseDisplayName: PropTypes.string,
|
||||
courseOrganization: PropTypes.string,
|
||||
modifiedDate: PropTypes.string,
|
||||
downloadUrl: PropTypes.string,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
uuid: PropTypes.string,
|
||||
})),
|
||||
};
|
||||
|
||||
Certificates.defaultProps = {
|
||||
certificates: [],
|
||||
};
|
||||
|
||||
export default connect(
|
||||
certificatesSelector,
|
||||
{},
|
||||
)(Certificates);
|
||||
@@ -1,23 +1,21 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const DateJoined = ({ date }) => {
|
||||
if (date == null) {
|
||||
return null;
|
||||
}
|
||||
if (!date) { return null; }
|
||||
|
||||
return (
|
||||
<p className="mb-0">
|
||||
<span className="small mb-0 text-gray-800">
|
||||
<FormattedMessage
|
||||
id="profile.datejoined.member.since"
|
||||
defaultMessage="Member since {year}"
|
||||
description="A label for how long the user has been a member"
|
||||
values={{
|
||||
year: <FormattedDate value={new Date(date)} year="numeric" />,
|
||||
year: <span className="font-weight-bold"> <FormattedDate value={new Date(date)} year="numeric" /> </span>,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,4 +26,4 @@ DateJoined.defaultProps = {
|
||||
date: null,
|
||||
};
|
||||
|
||||
export default DateJoined;
|
||||
export default memo(DateJoined);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const NotFoundPage = () => (
|
||||
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
|
||||
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
|
||||
<p className="my-0 py-5 text-muted max-width-32em">
|
||||
<FormattedMessage
|
||||
id="profile.notfound.message"
|
||||
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
|
||||
|
||||
@@ -1,37 +1,18 @@
|
||||
import React, { Component } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default class PageLoading extends Component {
|
||||
renderSrMessage() {
|
||||
if (!this.props.srMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="sr-only">
|
||||
{this.props.srMessage}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="d-flex justify-content-center align-items-center flex-column"
|
||||
style={{
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
{this.renderSrMessage()}
|
||||
</div>
|
||||
</div>
|
||||
const PageLoading = ({ srMessage }) => (
|
||||
<div>
|
||||
<div className="d-flex justify-content-center align-items-center flex-column height-50vh">
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
{srMessage && <span className="sr-only">{srMessage}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
PageLoading.propTypes = {
|
||||
srMessage: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default PageLoading;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import React from 'react';
|
||||
import React, {
|
||||
useEffect, useState, useContext, useCallback,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { ensureConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Hyperlink } from '@edx/paragon';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, Hyperlink, OverlayTrigger, Tooltip,
|
||||
} from '@openedx/paragon';
|
||||
import { InfoOutline } from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
// Actions
|
||||
import {
|
||||
fetchProfile,
|
||||
saveProfile,
|
||||
@@ -19,7 +25,6 @@ import {
|
||||
updateDraft,
|
||||
} from './data/actions';
|
||||
|
||||
// Components
|
||||
import ProfileAvatar from './forms/ProfileAvatar';
|
||||
import Name from './forms/Name';
|
||||
import Country from './forms/Country';
|
||||
@@ -27,121 +32,124 @@ import PreferredLanguage from './forms/PreferredLanguage';
|
||||
import Education from './forms/Education';
|
||||
import SocialLinks from './forms/SocialLinks';
|
||||
import Bio from './forms/Bio';
|
||||
import Certificates from './forms/Certificates';
|
||||
import AgeMessage from './AgeMessage';
|
||||
import DateJoined from './DateJoined';
|
||||
import UsernameDescription from './UsernameDescription';
|
||||
import UserCertificateSummary from './UserCertificateSummary';
|
||||
import PageLoading from './PageLoading';
|
||||
import Banner from './Banner';
|
||||
import LearningGoal from './forms/LearningGoal';
|
||||
import Certificates from './Certificates';
|
||||
|
||||
// Selectors
|
||||
import { profilePageSelector } from './data/selectors';
|
||||
|
||||
// i18n
|
||||
import messages from './ProfilePage.messages';
|
||||
|
||||
import withParams from '../utils/hoc';
|
||||
import { useIsOnMobileScreen, useIsOnTabletScreen } from './data/hooks';
|
||||
|
||||
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
|
||||
import AdditionalProfileFieldsSlot from '../plugin-slots/AdditionalProfileFieldsSlot';
|
||||
|
||||
class ProfilePage extends React.Component {
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL', 'ACCOUNT_SETTINGS_URL'], 'ProfilePage');
|
||||
|
||||
const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL;
|
||||
const ProfilePage = ({ params }) => {
|
||||
const dispatch = useDispatch();
|
||||
const intl = useIntl();
|
||||
const context = useContext(AppContext);
|
||||
const {
|
||||
dateJoined,
|
||||
courseCertificates,
|
||||
name,
|
||||
visibilityName,
|
||||
profileImage,
|
||||
savePhotoState,
|
||||
isLoadingProfile,
|
||||
photoUploadError,
|
||||
country,
|
||||
visibilityCountry,
|
||||
levelOfEducation,
|
||||
visibilityLevelOfEducation,
|
||||
socialLinks,
|
||||
draftSocialLinksByPlatform,
|
||||
visibilitySocialLinks,
|
||||
languageProficiencies,
|
||||
visibilityLanguageProficiencies,
|
||||
bio,
|
||||
visibilityBio,
|
||||
saveState,
|
||||
username,
|
||||
} = useSelector(profilePageSelector);
|
||||
|
||||
this.state = {
|
||||
viewMyRecordsUrl: credentialsBaseUrl ? `${credentialsBaseUrl}/records` : null,
|
||||
accountSettingsUrl: `${context.config.LMS_BASE_URL}/account/settings`,
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const [viewMyRecordsUrl, setViewMyRecordsUrl] = useState(null);
|
||||
const isMobileView = useIsOnMobileScreen();
|
||||
const isTabletView = useIsOnTabletScreen();
|
||||
|
||||
this.handleSaveProfilePhoto = this.handleSaveProfilePhoto.bind(this);
|
||||
this.handleDeleteProfilePhoto = this.handleDeleteProfilePhoto.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
useEffect(() => {
|
||||
const { CREDENTIALS_BASE_URL } = context.config;
|
||||
if (CREDENTIALS_BASE_URL) {
|
||||
setViewMyRecordsUrl(`${CREDENTIALS_BASE_URL}/records`);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchProfile(this.props.params.username);
|
||||
dispatch(fetchProfile(params.username));
|
||||
sendTrackingLogEvent('edx.profile.viewed', {
|
||||
username: this.props.params.username,
|
||||
username: params.username,
|
||||
});
|
||||
}
|
||||
}, [dispatch, params.username, context.config]);
|
||||
|
||||
handleSaveProfilePhoto(formData) {
|
||||
this.props.saveProfilePhoto(this.context.authenticatedUser.username, formData);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!username && saveState === 'error' && navigate) {
|
||||
navigate('/notfound');
|
||||
}
|
||||
}, [username, saveState, navigate]);
|
||||
|
||||
handleDeleteProfilePhoto() {
|
||||
this.props.deleteProfilePhoto(this.context.authenticatedUser.username);
|
||||
}
|
||||
const authenticatedUserName = context.authenticatedUser.username;
|
||||
|
||||
handleClose(formId) {
|
||||
this.props.closeForm(formId);
|
||||
}
|
||||
const handleSaveProfilePhoto = useCallback((formData) => {
|
||||
dispatch(saveProfilePhoto(authenticatedUserName, formData));
|
||||
}, [dispatch, authenticatedUserName]);
|
||||
|
||||
handleOpen(formId) {
|
||||
this.props.openForm(formId);
|
||||
}
|
||||
const handleDeleteProfilePhoto = useCallback(() => {
|
||||
dispatch(deleteProfilePhoto(authenticatedUserName));
|
||||
}, [dispatch, authenticatedUserName]);
|
||||
|
||||
handleSubmit(formId) {
|
||||
this.props.saveProfile(formId, this.context.authenticatedUser.username);
|
||||
}
|
||||
const handleClose = useCallback((formId) => {
|
||||
dispatch(closeForm(formId));
|
||||
}, [dispatch]);
|
||||
|
||||
handleChange(name, value) {
|
||||
this.props.updateDraft(name, value);
|
||||
}
|
||||
const handleOpen = useCallback((formId) => {
|
||||
dispatch(openForm(formId));
|
||||
}, [dispatch]);
|
||||
|
||||
isYOBDisabled() {
|
||||
const { yearOfBirth } = this.props;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const isAgeOrNotCompliant = !yearOfBirth || ((currentYear - yearOfBirth) < 13);
|
||||
const handleSubmit = useCallback((formId) => {
|
||||
dispatch(saveProfile(formId, authenticatedUserName));
|
||||
}, [dispatch, authenticatedUserName]);
|
||||
|
||||
return isAgeOrNotCompliant && getConfig().COLLECT_YEAR_OF_BIRTH !== 'true';
|
||||
}
|
||||
const handleChange = useCallback((fieldName, value) => {
|
||||
dispatch(updateDraft(fieldName, value));
|
||||
}, [dispatch]);
|
||||
|
||||
isAuthenticatedUserProfile() {
|
||||
return this.props.params.username === this.context.authenticatedUser.username;
|
||||
}
|
||||
const isAuthenticatedUserProfile = () => params.username === authenticatedUserName;
|
||||
|
||||
// Inserted into the DOM in two places (for responsive layout)
|
||||
renderViewMyRecordsButton() {
|
||||
if (!(this.state.viewMyRecordsUrl && this.isAuthenticatedUserProfile())) {
|
||||
const isBlockVisible = (blockInfo) => isAuthenticatedUserProfile()
|
||||
|| (!isAuthenticatedUserProfile() && Boolean(blockInfo));
|
||||
|
||||
const renderViewMyRecordsButton = () => {
|
||||
if (!(viewMyRecordsUrl && isAuthenticatedUserProfile())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Hyperlink className="btn btn-primary" destination={this.state.viewMyRecordsUrl} target="_blank">
|
||||
{this.props.intl.formatMessage(messages['profile.viewMyRecords'])}
|
||||
<Hyperlink
|
||||
className={classNames(
|
||||
'btn btn-brand bg-brand-500 font-weight-normal px-4 py-10px text-nowrap',
|
||||
{ 'w-100': isMobileView },
|
||||
)}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
destination={viewMyRecordsUrl}
|
||||
>
|
||||
{intl.formatMessage(messages['profile.viewMyRecords'])}
|
||||
</Hyperlink>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Inserted into the DOM in two places (for responsive layout)
|
||||
renderHeadingLockup() {
|
||||
const { dateJoined } = this.props;
|
||||
|
||||
return (
|
||||
<span data-hj-suppress>
|
||||
<h1 className="h2 mb-0 font-weight-bold text-truncate">{this.props.params.username}</h1>
|
||||
<DateJoined date={dateJoined} />
|
||||
{this.isYOBDisabled() && <UsernameDescription />}
|
||||
<hr className="d-none d-md-block" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
renderPhotoUploadErrorMessage() {
|
||||
const { photoUploadError } = this.props;
|
||||
|
||||
if (photoUploadError === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const renderPhotoUploadErrorMessage = () => (
|
||||
photoUploadError && (
|
||||
<div className="row">
|
||||
<div className="col-md-4 col-lg-3">
|
||||
<Alert variant="danger" dismissible={false} show>
|
||||
@@ -149,219 +157,270 @@ class ProfilePage extends React.Component {
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
renderAgeMessage() {
|
||||
const { requiresParentalConsent } = this.props;
|
||||
const shouldShowAgeMessage = requiresParentalConsent && this.isAuthenticatedUserProfile();
|
||||
const commonFormProps = {
|
||||
openHandler: handleOpen,
|
||||
closeHandler: handleClose,
|
||||
submitHandler: handleSubmit,
|
||||
changeHandler: handleChange,
|
||||
};
|
||||
|
||||
if (!shouldShowAgeMessage) {
|
||||
return null;
|
||||
}
|
||||
return <AgeMessage accountSettingsUrl={this.state.accountSettingsUrl} />;
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const {
|
||||
profileImage,
|
||||
name,
|
||||
visibilityName,
|
||||
country,
|
||||
visibilityCountry,
|
||||
levelOfEducation,
|
||||
visibilityLevelOfEducation,
|
||||
socialLinks,
|
||||
draftSocialLinksByPlatform,
|
||||
visibilitySocialLinks,
|
||||
learningGoal,
|
||||
visibilityLearningGoal,
|
||||
languageProficiencies,
|
||||
visibilityLanguageProficiencies,
|
||||
courseCertificates,
|
||||
visibilityCourseCertificates,
|
||||
bio,
|
||||
visibilityBio,
|
||||
requiresParentalConsent,
|
||||
isLoadingProfile,
|
||||
} = this.props;
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return <PageLoading srMessage={this.props.intl.formatMessage(messages['profile.loading'])} />;
|
||||
}
|
||||
|
||||
const commonFormProps = {
|
||||
openHandler: this.handleOpen,
|
||||
closeHandler: this.handleClose,
|
||||
submitHandler: this.handleSubmit,
|
||||
changeHandler: this.handleChange,
|
||||
};
|
||||
|
||||
const isBlockVisible = (blockInfo) => this.isAuthenticatedUserProfile()
|
||||
|| (!this.isAuthenticatedUserProfile() && Boolean(blockInfo));
|
||||
|
||||
const isLanguageBlockVisible = isBlockVisible(languageProficiencies.length);
|
||||
const isEducationBlockVisible = isBlockVisible(levelOfEducation);
|
||||
const isSocialLinksBLockVisible = isBlockVisible(socialLinks.some((link) => link.socialLink !== null));
|
||||
const isBioBlockVisible = isBlockVisible(bio);
|
||||
const isCertificatesBlockVisible = isBlockVisible(courseCertificates.length);
|
||||
const isNameBlockVisible = isBlockVisible(name);
|
||||
const isLocationBlockVisible = isBlockVisible(country);
|
||||
|
||||
return (
|
||||
<div className="container-fluid">
|
||||
<div className="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0">
|
||||
<div className="col-auto col-md-4 col-lg-3">
|
||||
<div className="d-flex align-items-center d-md-block">
|
||||
<ProfileAvatar
|
||||
className="mb-md-3"
|
||||
src={profileImage.src}
|
||||
isDefault={profileImage.isDefault}
|
||||
onSave={this.handleSaveProfilePhoto}
|
||||
onDelete={this.handleDeleteProfilePhoto}
|
||||
savePhotoState={this.props.savePhotoState}
|
||||
isEditable={this.isAuthenticatedUserProfile() && !requiresParentalConsent}
|
||||
/>
|
||||
return (
|
||||
<div className="profile-page">
|
||||
{isLoadingProfile ? (
|
||||
<PageLoading srMessage={intl.formatMessage(messages['profile.loading'])} />
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
'profile-page-bg-banner bg-primary d-md-block align-items-center h-100 w-100',
|
||||
{ 'px-3 py-4': isMobileView },
|
||||
{ 'px-120px py-5.5': !isMobileView },
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames([
|
||||
'col container-fluid w-100 h-100 bg-white py-0 rounded-75',
|
||||
{
|
||||
'px-3': isMobileView,
|
||||
'px-40px': !isMobileView,
|
||||
},
|
||||
])}
|
||||
>
|
||||
<div
|
||||
className={classNames([
|
||||
'col h-100 w-100 px-0 justify-content-start g-15rem',
|
||||
{
|
||||
'py-4': isMobileView,
|
||||
'py-36px': !isMobileView,
|
||||
},
|
||||
])}
|
||||
>
|
||||
<div
|
||||
className={classNames([
|
||||
'row-auto d-flex flex-wrap align-items-center h-100 w-100 justify-content-start g-15rem',
|
||||
isMobileView || isTabletView ? 'flex-column' : 'flex-row',
|
||||
])}
|
||||
>
|
||||
<ProfileAvatar
|
||||
className="col p-0"
|
||||
src={profileImage.src}
|
||||
isDefault={profileImage.isDefault}
|
||||
onSave={handleSaveProfilePhoto}
|
||||
onDelete={handleDeleteProfilePhoto}
|
||||
savePhotoState={savePhotoState}
|
||||
isEditable={isAuthenticatedUserProfile()}
|
||||
/>
|
||||
<div
|
||||
className={classNames([
|
||||
'col h-100 w-100 m-0 p-0',
|
||||
isMobileView || isTabletView
|
||||
? 'd-flex flex-column justify-content-center align-items-center'
|
||||
: 'justify-content-start align-items-start',
|
||||
])}
|
||||
>
|
||||
<p className="row m-0 font-weight-bold text-truncate text-primary-500 h3">
|
||||
{params.username}
|
||||
</p>
|
||||
{isBlockVisible(name) && (
|
||||
<p className="row pt-2 text-gray-800 font-weight-normal m-0 p">
|
||||
{name}
|
||||
</p>
|
||||
)}
|
||||
<div className={classNames(
|
||||
'row pt-2 m-0',
|
||||
isMobileView
|
||||
? 'd-flex justify-content-center align-items-center flex-column'
|
||||
: 'g-1rem',
|
||||
)}
|
||||
>
|
||||
<DateJoined date={dateJoined} />
|
||||
<UserCertificateSummary count={courseCertificates?.length || 0} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={classNames([
|
||||
'p-0 ',
|
||||
isMobileView || isTabletView ? 'col d-flex justify-content-center' : 'col-auto',
|
||||
])}
|
||||
>
|
||||
{renderViewMyRecordsButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
{renderPhotoUploadErrorMessage()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col">
|
||||
<div className="d-md-none">
|
||||
{this.renderHeadingLockup()}
|
||||
</div>
|
||||
<div className="d-none d-md-block float-right">
|
||||
{this.renderViewMyRecordsButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{this.renderPhotoUploadErrorMessage()}
|
||||
<div className="row">
|
||||
<div className="col-md-4 col-lg-4">
|
||||
<div className="d-none d-md-block mb-4">
|
||||
{this.renderHeadingLockup()}
|
||||
</div>
|
||||
<div className="d-md-none mb-4">
|
||||
{this.renderViewMyRecordsButton()}
|
||||
</div>
|
||||
{isNameBlockVisible && (
|
||||
<Name
|
||||
name={name}
|
||||
visibilityName={visibilityName}
|
||||
formId="name"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isLocationBlockVisible && (
|
||||
<Country
|
||||
country={country}
|
||||
visibilityCountry={visibilityCountry}
|
||||
formId="country"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isLanguageBlockVisible && (
|
||||
<PreferredLanguage
|
||||
languageProficiencies={languageProficiencies}
|
||||
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
|
||||
formId="languageProficiencies"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isEducationBlockVisible && (
|
||||
<Education
|
||||
levelOfEducation={levelOfEducation}
|
||||
visibilityLevelOfEducation={visibilityLevelOfEducation}
|
||||
formId="levelOfEducation"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isSocialLinksBLockVisible && (
|
||||
<SocialLinks
|
||||
socialLinks={socialLinks}
|
||||
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
|
||||
visibilitySocialLinks={visibilitySocialLinks}
|
||||
formId="socialLinks"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-md-3 col-md-8 col-lg-7 offset-lg-1">
|
||||
{!this.isYOBDisabled() && this.renderAgeMessage()}
|
||||
{isBioBlockVisible && (
|
||||
<Bio
|
||||
bio={bio}
|
||||
visibilityBio={visibilityBio}
|
||||
formId="bio"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{getConfig().ENABLE_SKILLS_BUILDER_PROFILE && (
|
||||
<LearningGoal
|
||||
learningGoal={learningGoal}
|
||||
visibilityLearningGoal={visibilityLearningGoal}
|
||||
formId="learningGoal"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isCertificatesBlockVisible && (
|
||||
<Certificates
|
||||
visibilityCourseCertificates={visibilityCourseCertificates}
|
||||
formId="certificates"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div
|
||||
className={classNames([
|
||||
'col d-inline-flex h-100 w-100 align-items-start justify-content-start g-3rem',
|
||||
isMobileView ? 'py-4 px-3' : 'px-120px py-6',
|
||||
])}
|
||||
>
|
||||
<div className="w-100 p-0">
|
||||
<div className="col justify-content-start align-items-start p-0">
|
||||
<div className="col align-self-stretch height-42px justify-content-start align-items-start p-0">
|
||||
<p className="font-weight-bold text-primary-500 m-0 h2">
|
||||
{isMobileView ? (
|
||||
<FormattedMessage
|
||||
id="profile.profile.information"
|
||||
defaultMessage="Profile"
|
||||
description="heading for the editable profile section in mobile view"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<FormattedMessage
|
||||
id="profile.profile.information"
|
||||
defaultMessage="Profile information"
|
||||
description="heading for the editable profile section"
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames([
|
||||
'row m-0 px-0 w-100 d-inline-flex align-items-start justify-content-start',
|
||||
isMobileView ? 'pt-4' : 'pt-5.5',
|
||||
])}
|
||||
>
|
||||
<div
|
||||
className={classNames([
|
||||
'col p-0',
|
||||
isMobileView ? 'col-12' : 'col-6',
|
||||
])}
|
||||
>
|
||||
<div className="m-0">
|
||||
<div className="row m-0 pb-1.5 align-items-center">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0">
|
||||
{intl.formatMessage(messages['profile.username'])}
|
||||
</p>
|
||||
<OverlayTrigger
|
||||
key="top"
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id="tooltip-top">
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.username.tooltip'])}
|
||||
</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<InfoOutline className="m-0 info-icon" />
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
<h4 className="edit-section-header text-gray-700">
|
||||
{params.username}
|
||||
</h4>
|
||||
</div>
|
||||
{isBlockVisible(name) && (
|
||||
<Name
|
||||
name={name}
|
||||
accountSettingsUrl={context.config.ACCOUNT_SETTINGS_URL}
|
||||
visibilityName={visibilityName}
|
||||
formId="name"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isBlockVisible(country) && (
|
||||
<Country
|
||||
country={country}
|
||||
visibilityCountry={visibilityCountry}
|
||||
formId="country"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isBlockVisible((languageProficiencies || []).length) && (
|
||||
<PreferredLanguage
|
||||
languageProficiencies={languageProficiencies || []}
|
||||
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
|
||||
formId="languageProficiencies"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
{isBlockVisible(levelOfEducation) && (
|
||||
<Education
|
||||
levelOfEducation={levelOfEducation}
|
||||
visibilityLevelOfEducation={visibilityLevelOfEducation}
|
||||
formId="levelOfEducation"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<Banner />
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
<AdditionalProfileFieldsSlot />
|
||||
</div>
|
||||
<div
|
||||
className={classNames([
|
||||
'col m-0 pr-0',
|
||||
isMobileView ? 'pl-0 col-12' : 'pl-40px col-6',
|
||||
])}
|
||||
>
|
||||
{isBlockVisible(bio) && (
|
||||
<Bio
|
||||
bio={bio}
|
||||
visibilityBio={visibilityBio}
|
||||
formId="bio"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
ProfilePage.contextType = AppContext;
|
||||
{isBlockVisible((socialLinks || []).some((link) => link?.socialLink !== null)) && (
|
||||
<SocialLinks
|
||||
socialLinks={socialLinks || []}
|
||||
draftSocialLinksByPlatform={draftSocialLinksByPlatform || {}}
|
||||
visibilitySocialLinks={visibilitySocialLinks}
|
||||
formId="socialLinks"
|
||||
{...commonFormProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classNames([
|
||||
'col container-fluid d-inline-flex bg-color-grey-FBFAF9 h-100 w-100 align-items-start justify-content-start g-3rem',
|
||||
isMobileView ? 'py-4 px-3' : 'px-120px py-6',
|
||||
])}
|
||||
>
|
||||
{isBlockVisible((courseCertificates || []).length) && (
|
||||
<Certificates
|
||||
certificates={courseCertificates || []}
|
||||
formId="certificates"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfilePage.propTypes = {
|
||||
// Account data
|
||||
params: PropTypes.shape({
|
||||
username: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
requiresParentalConsent: PropTypes.bool,
|
||||
dateJoined: PropTypes.string,
|
||||
|
||||
// Bio form data
|
||||
username: PropTypes.string,
|
||||
bio: PropTypes.string,
|
||||
yearOfBirth: PropTypes.number,
|
||||
visibilityBio: PropTypes.string.isRequired,
|
||||
|
||||
// Certificates form data
|
||||
visibilityBio: PropTypes.string,
|
||||
courseCertificates: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
})),
|
||||
visibilityCourseCertificates: PropTypes.string.isRequired,
|
||||
|
||||
// Country form data
|
||||
country: PropTypes.string,
|
||||
visibilityCountry: PropTypes.string.isRequired,
|
||||
|
||||
// Education form data
|
||||
visibilityCountry: PropTypes.string,
|
||||
levelOfEducation: PropTypes.string,
|
||||
visibilityLevelOfEducation: PropTypes.string.isRequired,
|
||||
|
||||
// Language proficiency form data
|
||||
visibilityLevelOfEducation: PropTypes.string,
|
||||
languageProficiencies: PropTypes.arrayOf(PropTypes.shape({
|
||||
code: PropTypes.string.isRequired,
|
||||
})),
|
||||
visibilityLanguageProficiencies: PropTypes.string.isRequired,
|
||||
|
||||
// Name form data
|
||||
visibilityLanguageProficiencies: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
visibilityName: PropTypes.string.isRequired,
|
||||
|
||||
// Social links form data
|
||||
visibilityName: PropTypes.string,
|
||||
socialLinks: PropTypes.arrayOf(PropTypes.shape({
|
||||
platform: PropTypes.string,
|
||||
socialLink: PropTypes.string,
|
||||
@@ -370,70 +429,40 @@ ProfilePage.propTypes = {
|
||||
platform: PropTypes.string,
|
||||
socialLink: PropTypes.string,
|
||||
})),
|
||||
visibilitySocialLinks: PropTypes.string.isRequired,
|
||||
|
||||
// Learning Goal form data
|
||||
learningGoal: PropTypes.string,
|
||||
visibilityLearningGoal: PropTypes.string.isRequired,
|
||||
|
||||
// Other data we need
|
||||
visibilitySocialLinks: PropTypes.string,
|
||||
profileImage: PropTypes.shape({
|
||||
src: PropTypes.string,
|
||||
isDefault: PropTypes.bool,
|
||||
}),
|
||||
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
|
||||
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
|
||||
isLoadingProfile: PropTypes.bool.isRequired,
|
||||
|
||||
// Page state helpers
|
||||
isLoadingProfile: PropTypes.bool,
|
||||
photoUploadError: PropTypes.objectOf(PropTypes.string),
|
||||
|
||||
// Actions
|
||||
fetchProfile: PropTypes.func.isRequired,
|
||||
saveProfile: PropTypes.func.isRequired,
|
||||
saveProfilePhoto: PropTypes.func.isRequired,
|
||||
deleteProfilePhoto: PropTypes.func.isRequired,
|
||||
openForm: PropTypes.func.isRequired,
|
||||
closeForm: PropTypes.func.isRequired,
|
||||
updateDraft: PropTypes.func.isRequired,
|
||||
|
||||
// Router
|
||||
params: PropTypes.shape({
|
||||
username: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
ProfilePage.defaultProps = {
|
||||
saveState: null,
|
||||
username: '',
|
||||
savePhotoState: null,
|
||||
photoUploadError: {},
|
||||
profileImage: {},
|
||||
name: null,
|
||||
yearOfBirth: null,
|
||||
levelOfEducation: null,
|
||||
country: null,
|
||||
socialLinks: [],
|
||||
draftSocialLinksByPlatform: {},
|
||||
bio: null,
|
||||
learningGoal: null,
|
||||
languageProficiencies: [],
|
||||
courseCertificates: null,
|
||||
courseCertificates: [],
|
||||
requiresParentalConsent: null,
|
||||
dateJoined: null,
|
||||
visibilityName: null,
|
||||
visibilityCountry: null,
|
||||
visibilityLevelOfEducation: null,
|
||||
visibilitySocialLinks: null,
|
||||
visibilityLanguageProficiencies: null,
|
||||
visibilityBio: null,
|
||||
isLoadingProfile: false,
|
||||
};
|
||||
|
||||
export default connect(
|
||||
profilePageSelector,
|
||||
{
|
||||
fetchProfile,
|
||||
saveProfilePhoto,
|
||||
deleteProfilePhoto,
|
||||
saveProfile,
|
||||
openForm,
|
||||
closeForm,
|
||||
updateDraft,
|
||||
},
|
||||
)(injectIntl(withParams(ProfilePage)));
|
||||
export default withParams(ProfilePage);
|
||||
|
||||
@@ -11,6 +11,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Profile loading...',
|
||||
description: 'Message displayed when the profile data is loading.',
|
||||
},
|
||||
'profile.username': {
|
||||
id: 'profile.username',
|
||||
defaultMessage: 'Username',
|
||||
description: 'Label for the username field.',
|
||||
},
|
||||
'profile.username.tooltip': {
|
||||
id: 'profile.username.tooltip',
|
||||
defaultMessage: 'The name that identifies you on edX. You cannot change your username.',
|
||||
description: 'Tooltip for the username field.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
/* eslint-disable global-require */
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import * as analytics from '@edx/frontend-platform/analytics';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { mount } from 'enzyme';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Provider } from 'react-redux';
|
||||
import renderer from 'react-test-renderer';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import {
|
||||
MemoryRouter,
|
||||
Routes,
|
||||
Route,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import messages from '../i18n';
|
||||
import ProfilePage from './ProfilePage';
|
||||
import loadingApp from './__mocks__/loadingApp.mockStore';
|
||||
import viewOwnProfile from './__mocks__/viewOwnProfile.mockStore';
|
||||
import viewOtherProfile from './__mocks__/viewOtherProfile.mockStore';
|
||||
import invalidUser from './__mocks__/invalidUser.mockStore';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
const storeMocks = {
|
||||
loadingApp: require('./__mocks__/loadingApp.mockStore'),
|
||||
viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore'),
|
||||
viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore'),
|
||||
savingEditedBio: require('./__mocks__/savingEditedBio.mockStore'),
|
||||
loadingApp,
|
||||
viewOwnProfile,
|
||||
viewOtherProfile,
|
||||
invalidUser,
|
||||
};
|
||||
|
||||
const requiredProfilePageProps = {
|
||||
fetchUserAccount: () => {},
|
||||
fetchProfile: () => {},
|
||||
saveProfile: () => {},
|
||||
saveProfilePhoto: () => {},
|
||||
deleteProfilePhoto: () => {},
|
||||
openField: () => {},
|
||||
closeField: () => {},
|
||||
params: { username: 'staff' },
|
||||
};
|
||||
|
||||
// Mock language cookie
|
||||
Object.defineProperty(global.document, 'cookie', {
|
||||
writable: true,
|
||||
value: `${getConfig().LANGUAGE_PREFERENCE_COOKIE_NAME}=en`,
|
||||
@@ -64,32 +71,39 @@ configureI18n({
|
||||
|
||||
beforeEach(() => {
|
||||
analytics.sendTrackingLogEvent.mockReset();
|
||||
useNavigate.mockReset();
|
||||
});
|
||||
|
||||
const ProfilePageWrapper = ({
|
||||
contextValue, store, params, requiresParentalConsent,
|
||||
contextValue, store, params,
|
||||
}) => (
|
||||
<AppContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
<AppContext.Provider value={contextValue}>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>
|
||||
<ProfilePage {...requiredProfilePageProps} params={params} requiresParentalConsent={requiresParentalConsent} />
|
||||
<MemoryRouter initialEntries={[`/profile/${params.username}`]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/profile/:username"
|
||||
element={<ProfilePage {...requiredProfilePageProps} params={params} />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
|
||||
ProfilePageWrapper.defaultProps = {
|
||||
// eslint-disable-next-line react/default-props-match-prop-types
|
||||
params: { username: 'staff' },
|
||||
requiresParentalConsent: null,
|
||||
};
|
||||
|
||||
ProfilePageWrapper.propTypes = {
|
||||
contextValue: PropTypes.shape({}).isRequired,
|
||||
store: PropTypes.shape({}).isRequired,
|
||||
params: PropTypes.shape({}),
|
||||
requiresParentalConsent: PropTypes.bool,
|
||||
params: PropTypes.shape({
|
||||
username: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
describe('<ProfilePage />', () => {
|
||||
@@ -99,8 +113,13 @@ describe('<ProfilePage />', () => {
|
||||
authenticatedUser: { userId: null, username: null, administrator: false },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.loadingApp)} />;
|
||||
const tree = renderer.create(component).toJSON();
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeMocks.loadingApp)}
|
||||
/>
|
||||
);
|
||||
const { container: tree } = render(component);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -109,8 +128,13 @@ describe('<ProfilePage />', () => {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.viewOwnProfile)} />;
|
||||
const tree = renderer.create(component).toJSON();
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeMocks.viewOwnProfile)}
|
||||
/>
|
||||
);
|
||||
const { container: tree } = render(component);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -119,7 +143,6 @@ describe('<ProfilePage />', () => {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
@@ -129,108 +152,29 @@ describe('<ProfilePage />', () => {
|
||||
...storeMocks.viewOtherProfile.profilePage,
|
||||
account: {
|
||||
...storeMocks.viewOtherProfile.profilePage.account,
|
||||
name: 'user',
|
||||
country: 'EN',
|
||||
bio: 'bio',
|
||||
courseCertificates: ['course certificates'],
|
||||
levelOfEducation: 'some level',
|
||||
languageProficiencies: ['some lang'],
|
||||
socialLinks: ['twitter'],
|
||||
timeZone: 'time zone',
|
||||
accountPrivacy: 'all_users',
|
||||
name: 'Verified User',
|
||||
country: 'US',
|
||||
bio: 'About me',
|
||||
courseCertificates: [{ title: 'Course 1' }],
|
||||
levelOfEducation: 'bachelors',
|
||||
languageProficiencies: [{ code: 'en' }],
|
||||
socialLinks: [{ platform: 'x', socialLink: 'https://x.com/user' }],
|
||||
},
|
||||
preferences: {
|
||||
...storeMocks.viewOtherProfile.profilePage.preferences,
|
||||
visibilityName: 'all_users',
|
||||
visibilityCountry: 'all_users',
|
||||
visibilityLevelOfEducation: 'all_users',
|
||||
visibilityLanguageProficiencies: 'all_users',
|
||||
visibilitySocialLinks: 'all_users',
|
||||
visibilityBio: 'all_users',
|
||||
},
|
||||
},
|
||||
})}
|
||||
match={{ params: { username: 'verified' } }} // Override default match
|
||||
params={{ username: 'verified' }}
|
||||
/>
|
||||
);
|
||||
const tree = renderer.create(component).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('while saving an edited bio', () => {
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeMocks.savingEditedBio)}
|
||||
/>
|
||||
);
|
||||
const tree = renderer.create(component).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('while saving an edited bio with error', () => {
|
||||
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
|
||||
storeData.profilePage.errors.bio = { userMessage: 'bio error' };
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeData)}
|
||||
/>
|
||||
);
|
||||
const tree = renderer.create(component).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('test country edit with error', () => {
|
||||
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
|
||||
storeData.profilePage.errors.country = { userMessage: 'country error' };
|
||||
storeData.profilePage.currentlyEditingField = 'country';
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeData)}
|
||||
/>
|
||||
);
|
||||
const tree = renderer.create(component).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('test education edit with error', () => {
|
||||
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
|
||||
storeData.profilePage.errors.levelOfEducation = { userMessage: 'education error' };
|
||||
storeData.profilePage.currentlyEditingField = 'levelOfEducation';
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeData)}
|
||||
/>
|
||||
);
|
||||
const tree = renderer.create(component).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('test preferreded language edit with error', () => {
|
||||
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
|
||||
storeData.profilePage.errors.languageProficiencies = { userMessage: 'preferred language error' };
|
||||
storeData.profilePage.currentlyEditingField = 'languageProficiencies';
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeData)}
|
||||
/>
|
||||
);
|
||||
const tree = renderer.create(component).toJSON();
|
||||
const { container: tree } = render(component);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
|
||||
@@ -248,41 +192,27 @@ describe('<ProfilePage />', () => {
|
||||
store={mockStore(storeMocks.viewOwnProfile)}
|
||||
/>
|
||||
);
|
||||
const tree = renderer.create(component).toJSON();
|
||||
const { container: tree } = render(component);
|
||||
expect(tree).toMatchSnapshot();
|
||||
});
|
||||
it('test age message alert', () => {
|
||||
const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
|
||||
storeData.userAccount.requiresParentalConsent = true;
|
||||
storeData.profilePage.account.requiresParentalConsent = true;
|
||||
|
||||
it('successfully redirected to not found page', () => {
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const navigate = jest.fn();
|
||||
useNavigate.mockReturnValue(navigate);
|
||||
const component = (
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeData)}
|
||||
requiresParentalConsent
|
||||
store={mockStore(storeMocks.invalidUser)}
|
||||
params={{ username: 'staffTest' }}
|
||||
/>
|
||||
);
|
||||
const wrapper = mount(component);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.alert-info').hasClass('show')).toBe(true);
|
||||
});
|
||||
it('test photo error alert', () => {
|
||||
const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
|
||||
storeData.profilePage.errors.photo = { userMessage: 'error' };
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
|
||||
};
|
||||
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeData)} />;
|
||||
const wrapper = mount(component);
|
||||
wrapper.update();
|
||||
|
||||
expect(wrapper.find('.alert-danger').hasClass('show')).toBe(true);
|
||||
const { container: tree } = render(component);
|
||||
expect(tree).toMatchSnapshot();
|
||||
expect(navigate).toHaveBeenCalledWith('/notfound');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,21 +222,38 @@ describe('<ProfilePage />', () => {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const component = (
|
||||
render(
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeMocks.loadingApp)}
|
||||
params={{ username: 'test-username' }}
|
||||
/>
|
||||
/>,
|
||||
);
|
||||
const wrapper = mount(component);
|
||||
wrapper.update();
|
||||
|
||||
expect(analytics.sendTrackingLogEvent.mock.calls.length).toBe(1);
|
||||
expect(analytics.sendTrackingLogEvent.mock.calls[0][0]).toEqual('edx.profile.viewed');
|
||||
expect(analytics.sendTrackingLogEvent.mock.calls[0][1]).toEqual({
|
||||
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledTimes(1);
|
||||
expect(analytics.sendTrackingLogEvent).toHaveBeenCalledWith('edx.profile.viewed', {
|
||||
username: 'test-username',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handles navigation', () => {
|
||||
it('navigates to notfound on save error with no username', () => {
|
||||
const contextValue = {
|
||||
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
|
||||
config: getConfig(),
|
||||
};
|
||||
const navigate = jest.fn();
|
||||
useNavigate.mockReturnValue(navigate);
|
||||
render(
|
||||
<ProfilePageWrapper
|
||||
contextValue={contextValue}
|
||||
store={mockStore(storeMocks.invalidUser)}
|
||||
params={{ username: 'staffTest' }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(navigate).toHaveBeenCalledWith('/notfound');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
27
src/profile/UserCertificateSummary.jsx
Normal file
27
src/profile/UserCertificateSummary.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const UserCertificateSummary = ({ count = 0 }) => {
|
||||
if (count) {
|
||||
return (
|
||||
<span className="small m-0 text-gray-800">
|
||||
<FormattedMessage
|
||||
id="profile.certificatecount"
|
||||
defaultMessage="{certificate_count} certifications"
|
||||
description="A label for many certificates a user has"
|
||||
values={{
|
||||
certificate_count: <span className="font-weight-bold">{count}</span>,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
UserCertificateSummary.propTypes = {
|
||||
count: PropTypes.number,
|
||||
};
|
||||
|
||||
export default UserCertificateSummary;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { VisibilityOff } from '@edx/paragon/icons';
|
||||
import { Icon } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
const UsernameDescription = () => (
|
||||
<div className="d-flex align-items-center mt-3 mb-2rem">
|
||||
<Icon src={VisibilityOff} className="icon-visibility-off" />
|
||||
<div className="username-description">
|
||||
<FormattedMessage
|
||||
id="profile.username.description"
|
||||
defaultMessage="Your profile information is only visible to you. Only your username is visible to others on {siteName}."
|
||||
description="A description of the username field"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default UsernameDescription;
|
||||
42
src/profile/__mocks__/invalidUser.mockStore.js
Normal file
42
src/profile/__mocks__/invalidUser.mockStore.js
Normal file
@@ -0,0 +1,42 @@
|
||||
module.exports = {
|
||||
userAccount: {
|
||||
loading: false,
|
||||
error: null,
|
||||
username: 'staff',
|
||||
email: null,
|
||||
bio: null,
|
||||
name: null,
|
||||
country: null,
|
||||
socialLinks: null,
|
||||
profileImage: {
|
||||
imageUrlMedium: null,
|
||||
imageUrlLarge: null
|
||||
},
|
||||
levelOfEducation: null,
|
||||
learningGoal: null
|
||||
},
|
||||
profilePage: {
|
||||
errors: {},
|
||||
saveState: 'error',
|
||||
savePhotoState: null,
|
||||
currentlyEditingField: null,
|
||||
account: {
|
||||
username: '',
|
||||
socialLinks: []
|
||||
},
|
||||
preferences: {},
|
||||
courseCertificates: [],
|
||||
drafts: {},
|
||||
isLoadingProfile: false,
|
||||
isAuthenticatedUserProfile: true,
|
||||
countriesCodesList: ['US', 'CA', 'GB', 'ME']
|
||||
},
|
||||
router: {
|
||||
location: {
|
||||
pathname: '/u/staffTest',
|
||||
search: '',
|
||||
hash: ''
|
||||
},
|
||||
action: 'POP'
|
||||
}
|
||||
};
|
||||
@@ -29,6 +29,7 @@ module.exports = {
|
||||
drafts: {},
|
||||
isLoadingProfile: true,
|
||||
isAuthenticatedUserProfile: true,
|
||||
countriesCodesList: ['US', 'CA', 'GB', 'ME']
|
||||
},
|
||||
router: {
|
||||
location: {
|
||||
|
||||
@@ -13,8 +13,8 @@ module.exports = {
|
||||
socialLink: 'https://www.facebook.com/aloha'
|
||||
},
|
||||
{
|
||||
platform: 'twitter',
|
||||
socialLink: 'https://www.twitter.com/ALOHA'
|
||||
platform: 'x',
|
||||
socialLink: 'https://www.x.com/ALOHA'
|
||||
}
|
||||
],
|
||||
profileImage: {
|
||||
@@ -85,8 +85,8 @@ module.exports = {
|
||||
socialLink: 'https://www.facebook.com/aloha'
|
||||
},
|
||||
{
|
||||
platform: 'twitter',
|
||||
socialLink: 'https://www.twitter.com/ALOHA'
|
||||
platform: 'x',
|
||||
socialLink: 'https://www.x.com/ALOHA'
|
||||
}
|
||||
],
|
||||
timeZone: null,
|
||||
@@ -125,7 +125,8 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
drafts: {},
|
||||
isLoadingProfile: false
|
||||
isLoadingProfile: false,
|
||||
disabledCountries: [],
|
||||
},
|
||||
router: {
|
||||
location: {
|
||||
|
||||
@@ -13,8 +13,8 @@ module.exports = {
|
||||
socialLink: 'https://www.facebook.com/aloha'
|
||||
},
|
||||
{
|
||||
platform: 'twitter',
|
||||
socialLink: 'https://www.twitter.com/ALOHA'
|
||||
platform: 'x',
|
||||
socialLink: 'https://www.x.com/ALOHA'
|
||||
}
|
||||
],
|
||||
profileImage: {
|
||||
@@ -81,11 +81,18 @@ module.exports = {
|
||||
gender: null,
|
||||
accountPrivacy: 'private'
|
||||
},
|
||||
preferences: {},
|
||||
preferences: {
|
||||
visibilityName: 'all_users',
|
||||
visibilityCountry: 'all_users',
|
||||
visibilityLevelOfEducation: 'all_users',
|
||||
visibilityLanguageProficiencies: 'all_users',
|
||||
visibilitySocialLinks: 'all_users',
|
||||
visibilityBio: 'all_users'
|
||||
},
|
||||
courseCertificates: [],
|
||||
drafts: {},
|
||||
isLoadingProfile: false,
|
||||
learningGoal: 'advance_career',
|
||||
countriesCodesList: ['US', 'CA', 'GB', 'ME']
|
||||
},
|
||||
router: {
|
||||
location: {
|
||||
|
||||
@@ -13,8 +13,8 @@ module.exports = {
|
||||
socialLink: 'https://www.facebook.com/aloha'
|
||||
},
|
||||
{
|
||||
platform: 'twitter',
|
||||
socialLink: 'https://www.twitter.com/ALOHA'
|
||||
platform: 'x',
|
||||
socialLink: 'https://www.x.com/ALOHA'
|
||||
}
|
||||
],
|
||||
profileImage: {
|
||||
@@ -85,8 +85,8 @@ module.exports = {
|
||||
socialLink: 'https://www.facebook.com/aloha'
|
||||
},
|
||||
{
|
||||
platform: 'twitter',
|
||||
socialLink: 'https://www.twitter.com/ALOHA'
|
||||
platform: 'x',
|
||||
socialLink: 'https://www.x.com/ALOHA'
|
||||
}
|
||||
],
|
||||
timeZone: null,
|
||||
@@ -125,7 +125,8 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
drafts: {},
|
||||
isLoadingProfile: false
|
||||
isLoadingProfile: false,
|
||||
countriesCodesList: ['US', 'CA', 'GB', 'ME']
|
||||
},
|
||||
router: {
|
||||
location: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,6 @@ export const CLOSE_FORM = 'CLOSE_FORM';
|
||||
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
|
||||
export const RESET_DRAFTS = 'RESET_DRAFTS';
|
||||
|
||||
// FETCH PROFILE ACTIONS
|
||||
|
||||
export const fetchProfile = username => ({
|
||||
type: FETCH_PROFILE.BASE,
|
||||
payload: { username },
|
||||
@@ -25,20 +23,20 @@ export const fetchProfileSuccess = (
|
||||
preferences,
|
||||
courseCertificates,
|
||||
isAuthenticatedUserProfile,
|
||||
countriesCodesList,
|
||||
) => ({
|
||||
type: FETCH_PROFILE.SUCCESS,
|
||||
account,
|
||||
preferences,
|
||||
courseCertificates,
|
||||
isAuthenticatedUserProfile,
|
||||
countriesCodesList,
|
||||
});
|
||||
|
||||
export const fetchProfileReset = () => ({
|
||||
type: FETCH_PROFILE.RESET,
|
||||
});
|
||||
|
||||
// SAVE PROFILE ACTIONS
|
||||
|
||||
export const saveProfile = (formId, username) => ({
|
||||
type: SAVE_PROFILE.BASE,
|
||||
payload: {
|
||||
@@ -68,8 +66,6 @@ export const saveProfileFailure = errors => ({
|
||||
payload: { errors },
|
||||
});
|
||||
|
||||
// SAVE PROFILE PHOTO ACTIONS
|
||||
|
||||
export const saveProfilePhoto = (username, formData) => ({
|
||||
type: SAVE_PROFILE_PHOTO.BASE,
|
||||
payload: {
|
||||
@@ -96,8 +92,6 @@ export const saveProfilePhotoFailure = error => ({
|
||||
payload: { error },
|
||||
});
|
||||
|
||||
// DELETE PROFILE PHOTO ACTIONS
|
||||
|
||||
export const deleteProfilePhoto = username => ({
|
||||
type: DELETE_PROFILE_PHOTO.BASE,
|
||||
payload: {
|
||||
@@ -118,8 +112,6 @@ export const deleteProfilePhotoReset = () => ({
|
||||
type: DELETE_PROFILE_PHOTO.RESET,
|
||||
});
|
||||
|
||||
// FIELD STATE ACTIONS
|
||||
|
||||
export const openForm = formId => ({
|
||||
type: OPEN_FORM,
|
||||
payload: {
|
||||
@@ -134,8 +126,6 @@ export const closeForm = formId => ({
|
||||
},
|
||||
});
|
||||
|
||||
// FORM STATE ACTIONS
|
||||
|
||||
export const updateDraft = (name, value) => ({
|
||||
type: UPDATE_DRAFT,
|
||||
payload: {
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
openForm,
|
||||
closeForm,
|
||||
OPEN_FORM,
|
||||
CLOSE_FORM,
|
||||
SAVE_PROFILE,
|
||||
saveProfileBegin,
|
||||
saveProfileSuccess,
|
||||
saveProfileFailure,
|
||||
saveProfileReset,
|
||||
saveProfile,
|
||||
SAVE_PROFILE_PHOTO,
|
||||
saveProfilePhotoBegin,
|
||||
saveProfilePhotoSuccess,
|
||||
@@ -22,76 +12,6 @@ import {
|
||||
deleteProfilePhoto,
|
||||
} from './actions';
|
||||
|
||||
describe('editable field actions', () => {
|
||||
it('should create an open action', () => {
|
||||
const expectedAction = {
|
||||
type: OPEN_FORM,
|
||||
payload: {
|
||||
formId: 'name',
|
||||
},
|
||||
};
|
||||
expect(openForm('name')).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create a closed action', () => {
|
||||
const expectedAction = {
|
||||
type: CLOSE_FORM,
|
||||
payload: {
|
||||
formId: 'name',
|
||||
},
|
||||
};
|
||||
expect(closeForm('name')).toEqual(expectedAction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAVE profile actions', () => {
|
||||
it('should create an action to signal the start of a profile save', () => {
|
||||
const expectedAction = {
|
||||
type: SAVE_PROFILE.BASE,
|
||||
payload: {
|
||||
formId: 'name',
|
||||
},
|
||||
};
|
||||
expect(saveProfile('name')).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to signal user profile save success', () => {
|
||||
const accountData = { name: 'Full Name' };
|
||||
const preferencesData = { visibility: { name: 'private' } };
|
||||
const expectedAction = {
|
||||
type: SAVE_PROFILE.SUCCESS,
|
||||
payload: {
|
||||
account: accountData,
|
||||
preferences: preferencesData,
|
||||
},
|
||||
};
|
||||
expect(saveProfileSuccess(accountData, preferencesData)).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to signal user profile save beginning', () => {
|
||||
const expectedAction = {
|
||||
type: SAVE_PROFILE.BEGIN,
|
||||
};
|
||||
expect(saveProfileBegin()).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to signal user profile save success', () => {
|
||||
const expectedAction = {
|
||||
type: SAVE_PROFILE.RESET,
|
||||
};
|
||||
expect(saveProfileReset()).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to signal user account save failure', () => {
|
||||
const errors = ['Test failure'];
|
||||
const expectedAction = {
|
||||
type: SAVE_PROFILE.FAILURE,
|
||||
payload: { errors },
|
||||
};
|
||||
expect(saveProfileFailure(errors)).toEqual(expectedAction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAVE profile photo actions', () => {
|
||||
it('should create an action to signal the start of a profile photo save', () => {
|
||||
const formData = 'multipart form data';
|
||||
@@ -123,7 +43,7 @@ describe('SAVE profile photo actions', () => {
|
||||
expect(saveProfilePhotoSuccess(newPhotoData)).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to signal user profile photo save success', () => {
|
||||
it('should create an action to signal user profile photo save reset', () => {
|
||||
const expectedAction = {
|
||||
type: SAVE_PROFILE_PHOTO.RESET,
|
||||
};
|
||||
@@ -169,34 +89,10 @@ describe('DELETE profile photo actions', () => {
|
||||
expect(deleteProfilePhotoSuccess(defaultPhotoData)).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to signal user profile photo deletion success', () => {
|
||||
it('should create an action to signal user profile photo deletion reset', () => {
|
||||
const expectedAction = {
|
||||
type: DELETE_PROFILE_PHOTO.RESET,
|
||||
};
|
||||
expect(deleteProfilePhotoReset()).toEqual(expectedAction);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editable field opening and closing actions', () => {
|
||||
const formId = 'name';
|
||||
|
||||
it('should create an action to signal the opening a field', () => {
|
||||
const expectedAction = {
|
||||
type: OPEN_FORM,
|
||||
payload: {
|
||||
formId,
|
||||
},
|
||||
};
|
||||
expect(openForm(formId)).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to signal the closing a field', () => {
|
||||
const expectedAction = {
|
||||
type: CLOSE_FORM,
|
||||
payload: {
|
||||
formId,
|
||||
},
|
||||
};
|
||||
expect(closeForm(formId)).toEqual(expectedAction);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,15 +14,20 @@ const SOCIAL = {
|
||||
linkedin: {
|
||||
title: 'LinkedIn',
|
||||
},
|
||||
twitter: {
|
||||
title: 'Twitter',
|
||||
x: {
|
||||
title: 'X',
|
||||
},
|
||||
facebook: {
|
||||
title: 'Facebook',
|
||||
},
|
||||
};
|
||||
|
||||
const FIELD_LABELS = {
|
||||
COUNTRY: 'country',
|
||||
};
|
||||
|
||||
export {
|
||||
EDUCATION_LEVELS,
|
||||
SOCIAL,
|
||||
FIELD_LABELS,
|
||||
};
|
||||
|
||||
34
src/profile/data/hooks.js
Normal file
34
src/profile/data/hooks.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { breakpoints, useWindowSize } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export function useIsOnTabletScreen() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width <= breakpoints.medium.minWidth;
|
||||
}
|
||||
|
||||
export function useIsOnMobileScreen() {
|
||||
const windowSize = useWindowSize();
|
||||
return windowSize.width <= breakpoints.small.minWidth;
|
||||
}
|
||||
|
||||
export function useIsVisibilityEnabled() {
|
||||
return getConfig().DISABLE_VISIBILITY_EDITING !== 'true';
|
||||
}
|
||||
|
||||
export function useHandleChange(changeHandler) {
|
||||
return (e) => {
|
||||
const { name, value } = e.target;
|
||||
changeHandler(name, value);
|
||||
};
|
||||
}
|
||||
|
||||
export function useHandleSubmit(submitHandler, formId) {
|
||||
return (e) => {
|
||||
e.preventDefault();
|
||||
submitHandler(formId);
|
||||
};
|
||||
}
|
||||
|
||||
export function useCloseOpenHandler(handler, formId) {
|
||||
return () => handler(formId);
|
||||
}
|
||||
@@ -17,6 +17,10 @@ const expectedUserInfo200 = {
|
||||
dateJoined: '2017-06-07T00:44:23Z',
|
||||
isActive: true,
|
||||
yearOfBirth: 1901,
|
||||
languageProficiencies: [],
|
||||
levelOfEducation: null,
|
||||
profileImage: {},
|
||||
socialLinks: [],
|
||||
};
|
||||
|
||||
const provider = new PactV3({
|
||||
|
||||
@@ -16,12 +16,28 @@ export const initialState = {
|
||||
currentlyEditingField: null,
|
||||
account: {
|
||||
socialLinks: [],
|
||||
languageProficiencies: [],
|
||||
name: '',
|
||||
bio: '',
|
||||
country: '',
|
||||
levelOfEducation: '',
|
||||
profileImage: {},
|
||||
yearOfBirth: '',
|
||||
},
|
||||
preferences: {
|
||||
visibilityName: '',
|
||||
visibilityBio: '',
|
||||
visibilityCountry: '',
|
||||
visibilityLevelOfEducation: '',
|
||||
visibilitySocialLinks: '',
|
||||
visibilityLanguageProficiencies: '',
|
||||
},
|
||||
preferences: {},
|
||||
courseCertificates: [],
|
||||
drafts: {},
|
||||
isLoadingProfile: true,
|
||||
isAuthenticatedUserProfile: false,
|
||||
disabledCountries: ['RU'],
|
||||
countriesCodesList: [],
|
||||
};
|
||||
|
||||
const profilePage = (state = initialState, action = {}) => {
|
||||
@@ -37,11 +53,17 @@ const profilePage = (state = initialState, action = {}) => {
|
||||
case FETCH_PROFILE.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
account: action.account,
|
||||
account: {
|
||||
...state.account,
|
||||
...action.account,
|
||||
socialLinks: action.account.socialLinks || [],
|
||||
languageProficiencies: action.account.languageProficiencies || [],
|
||||
},
|
||||
preferences: action.preferences,
|
||||
courseCertificates: action.courseCertificates,
|
||||
courseCertificates: action.courseCertificates || [],
|
||||
isLoadingProfile: false,
|
||||
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
|
||||
countriesCodesList: action.countriesCodesList || [],
|
||||
};
|
||||
case SAVE_PROFILE.BEGIN:
|
||||
return {
|
||||
@@ -54,24 +76,28 @@ const profilePage = (state = initialState, action = {}) => {
|
||||
...state,
|
||||
saveState: 'complete',
|
||||
errors: {},
|
||||
// Account is always replaced completely.
|
||||
account: action.payload.account !== null ? action.payload.account : state.account,
|
||||
// Preferences changes get merged in.
|
||||
account: action.payload.account !== null ? {
|
||||
...state.account,
|
||||
...action.payload.account,
|
||||
socialLinks: action.payload.account.socialLinks || [],
|
||||
languageProficiencies: action.payload.account.languageProficiencies || [],
|
||||
} : state.account,
|
||||
preferences: { ...state.preferences, ...action.payload.preferences },
|
||||
};
|
||||
case SAVE_PROFILE.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
saveState: 'error',
|
||||
isLoadingProfile: false,
|
||||
errors: { ...state.errors, ...action.payload.errors },
|
||||
};
|
||||
case SAVE_PROFILE.RESET:
|
||||
return {
|
||||
...state,
|
||||
saveState: null,
|
||||
isLoadingProfile: false,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
case SAVE_PROFILE_PHOTO.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
@@ -81,7 +107,6 @@ const profilePage = (state = initialState, action = {}) => {
|
||||
case SAVE_PROFILE_PHOTO.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
// Merge in new profile image data
|
||||
account: { ...state.account, profileImage: action.payload.profileImage },
|
||||
savePhotoState: 'complete',
|
||||
errors: {},
|
||||
@@ -98,7 +123,6 @@ const profilePage = (state = initialState, action = {}) => {
|
||||
savePhotoState: null,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
case DELETE_PROFILE_PHOTO.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
@@ -108,7 +132,6 @@ const profilePage = (state = initialState, action = {}) => {
|
||||
case DELETE_PROFILE_PHOTO.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
// Merge in new profile image data (should be empty or default image)
|
||||
account: { ...state.account, profileImage: action.payload.profileImage },
|
||||
savePhotoState: 'complete',
|
||||
errors: {},
|
||||
@@ -125,13 +148,11 @@ const profilePage = (state = initialState, action = {}) => {
|
||||
savePhotoState: null,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
case UPDATE_DRAFT:
|
||||
return {
|
||||
...state,
|
||||
drafts: { ...state.drafts, [action.payload.name]: action.payload.value },
|
||||
};
|
||||
|
||||
case RESET_DRAFTS:
|
||||
return {
|
||||
...state,
|
||||
@@ -144,7 +165,6 @@ const profilePage = (state = initialState, action = {}) => {
|
||||
drafts: {},
|
||||
};
|
||||
case CLOSE_FORM:
|
||||
// Only close if the field to close is undefined or matches the field that is currently open
|
||||
if (action.payload.formId === state.currentlyEditingField) {
|
||||
return {
|
||||
...state,
|
||||
|
||||
309
src/profile/data/reducers.test.js
Normal file
309
src/profile/data/reducers.test.js
Normal file
@@ -0,0 +1,309 @@
|
||||
import profilePage, { initialState } from './reducers';
|
||||
import {
|
||||
SAVE_PROFILE,
|
||||
SAVE_PROFILE_PHOTO,
|
||||
DELETE_PROFILE_PHOTO,
|
||||
CLOSE_FORM,
|
||||
OPEN_FORM,
|
||||
FETCH_PROFILE,
|
||||
UPDATE_DRAFT,
|
||||
RESET_DRAFTS,
|
||||
} from './actions';
|
||||
|
||||
describe('profilePage reducer', () => {
|
||||
it('should return the initial state by default', () => {
|
||||
expect(profilePage(undefined, {})).toEqual(initialState);
|
||||
});
|
||||
|
||||
describe('FETCH_PROFILE actions', () => {
|
||||
it('should handle FETCH_PROFILE.BEGIN', () => {
|
||||
const action = { type: FETCH_PROFILE.BEGIN };
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle FETCH_PROFILE.SUCCESS', () => {
|
||||
const action = {
|
||||
type: FETCH_PROFILE.SUCCESS,
|
||||
account: {
|
||||
name: 'John Doe',
|
||||
bio: 'Software Engineer',
|
||||
country: 'US',
|
||||
levelOfEducation: 'bachelors',
|
||||
socialLinks: [{ platform: 'x', link: 'x.com/johndoe' }],
|
||||
languageProficiencies: [{ code: 'en', name: 'English' }],
|
||||
profileImage: { url: 'profile.jpg' },
|
||||
yearOfBirth: 1990,
|
||||
},
|
||||
preferences: {
|
||||
visibilityName: 'public',
|
||||
visibilityBio: 'public',
|
||||
visibilityCountry: 'public',
|
||||
visibilityLevelOfEducation: 'public',
|
||||
visibilitySocialLinks: 'public',
|
||||
visibilityLanguageProficiencies: 'public',
|
||||
},
|
||||
courseCertificates: ['cert1', 'cert2'],
|
||||
isAuthenticatedUserProfile: true,
|
||||
countriesCodesList: ['US', 'CA'],
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
account: {
|
||||
...initialState.account,
|
||||
...action.account,
|
||||
socialLinks: action.account.socialLinks,
|
||||
languageProficiencies: action.account.languageProficiencies,
|
||||
},
|
||||
preferences: action.preferences,
|
||||
courseCertificates: action.courseCertificates,
|
||||
isLoadingProfile: false,
|
||||
isAuthenticatedUserProfile: action.isAuthenticatedUserProfile,
|
||||
countriesCodesList: action.countriesCodesList,
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAVE_PROFILE actions', () => {
|
||||
it('should handle SAVE_PROFILE.BEGIN', () => {
|
||||
const action = { type: SAVE_PROFILE.BEGIN };
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
saveState: 'pending',
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SAVE_PROFILE.SUCCESS', () => {
|
||||
const action = {
|
||||
type: SAVE_PROFILE.SUCCESS,
|
||||
payload: {
|
||||
account: {
|
||||
name: 'Jane Doe',
|
||||
bio: 'Updated bio',
|
||||
socialLinks: [{ platform: 'linkedin', link: 'linkedin.com/janedoe' }],
|
||||
languageProficiencies: [{ code: 'es', name: 'Spanish' }],
|
||||
},
|
||||
preferences: {
|
||||
visibilityName: 'private',
|
||||
visibilityBio: 'private',
|
||||
},
|
||||
},
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
saveState: 'complete',
|
||||
errors: {},
|
||||
account: {
|
||||
...initialState.account,
|
||||
...action.payload.account,
|
||||
socialLinks: action.payload.account.socialLinks,
|
||||
languageProficiencies: action.payload.account.languageProficiencies,
|
||||
},
|
||||
preferences: {
|
||||
...initialState.preferences,
|
||||
...action.payload.preferences,
|
||||
},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SAVE_PROFILE.FAILURE', () => {
|
||||
const action = {
|
||||
type: SAVE_PROFILE.FAILURE,
|
||||
payload: { errors: { save: 'Failed to save profile' } },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
saveState: 'error',
|
||||
isLoadingProfile: false,
|
||||
errors: { save: action.payload.errors.save },
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SAVE_PROFILE.RESET', () => {
|
||||
const action = { type: SAVE_PROFILE.RESET };
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
saveState: null,
|
||||
isLoadingProfile: false,
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAVE_PROFILE_PHOTO actions', () => {
|
||||
it('should handle SAVE_PROFILE_PHOTO.BEGIN', () => {
|
||||
const action = { type: SAVE_PROFILE_PHOTO.BEGIN };
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
savePhotoState: 'pending',
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SAVE_PROFILE_PHOTO.SUCCESS', () => {
|
||||
const action = {
|
||||
type: SAVE_PROFILE_PHOTO.SUCCESS,
|
||||
payload: { profileImage: { url: 'new-image-url.jpg' } },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
account: { ...initialState.account, profileImage: action.payload.profileImage },
|
||||
savePhotoState: 'complete',
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SAVE_PROFILE_PHOTO.FAILURE', () => {
|
||||
const action = {
|
||||
type: SAVE_PROFILE_PHOTO.FAILURE,
|
||||
payload: { error: 'Photo upload failed' },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
savePhotoState: 'error',
|
||||
errors: { photo: action.payload.error },
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle SAVE_PROFILE_PHOTO.RESET', () => {
|
||||
const action = { type: SAVE_PROFILE_PHOTO.RESET };
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
savePhotoState: null,
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE_PROFILE_PHOTO actions', () => {
|
||||
it('should handle DELETE_PROFILE_PHOTO.BEGIN', () => {
|
||||
const action = { type: DELETE_PROFILE_PHOTO.BEGIN };
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
savePhotoState: 'pending',
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle DELETE_PROFILE_PHOTO.SUCCESS', () => {
|
||||
const action = {
|
||||
type: DELETE_PROFILE_PHOTO.SUCCESS,
|
||||
payload: { profileImage: { url: 'default-image-url.jpg' } },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
account: { ...initialState.account, profileImage: action.payload.profileImage },
|
||||
savePhotoState: 'complete',
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle DELETE_PROFILE_PHOTO.FAILURE', () => {
|
||||
const action = {
|
||||
type: DELETE_PROFILE_PHOTO.FAILURE,
|
||||
payload: { errors: { delete: 'Failed to delete photo' } },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
savePhotoState: 'error',
|
||||
errors: { delete: action.payload.errors.delete },
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle DELETE_PROFILE_PHOTO.RESET', () => {
|
||||
const action = { type: DELETE_PROFILE_PHOTO.RESET };
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
savePhotoState: null,
|
||||
errors: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Draft and Form actions', () => {
|
||||
it('should handle UPDATE_DRAFT', () => {
|
||||
const action = {
|
||||
type: UPDATE_DRAFT,
|
||||
payload: { name: 'bio', value: 'New bio draft' },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
drafts: { bio: 'New bio draft' },
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle RESET_DRAFTS', () => {
|
||||
const initialStateWithDrafts = {
|
||||
...initialState,
|
||||
drafts: { bio: 'New bio draft', name: 'New name' },
|
||||
};
|
||||
const action = { type: RESET_DRAFTS };
|
||||
const expectedState = {
|
||||
...initialStateWithDrafts,
|
||||
drafts: {},
|
||||
};
|
||||
expect(profilePage(initialStateWithDrafts, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle OPEN_FORM', () => {
|
||||
const action = {
|
||||
type: OPEN_FORM,
|
||||
payload: { formId: 'bioForm' },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialState,
|
||||
currentlyEditingField: 'bioForm',
|
||||
drafts: {},
|
||||
};
|
||||
expect(profilePage(initialState, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should handle CLOSE_FORM when formId matches currentlyEditingField', () => {
|
||||
const initialStateWithForm = {
|
||||
...initialState,
|
||||
currentlyEditingField: 'bioForm',
|
||||
drafts: { bio: 'New bio draft' },
|
||||
};
|
||||
const action = {
|
||||
type: CLOSE_FORM,
|
||||
payload: { formId: 'bioForm' },
|
||||
};
|
||||
const expectedState = {
|
||||
...initialStateWithForm,
|
||||
currentlyEditingField: null,
|
||||
drafts: {},
|
||||
};
|
||||
expect(profilePage(initialStateWithForm, action)).toEqual(expectedState);
|
||||
});
|
||||
|
||||
it('should not handle CLOSE_FORM when formId does not match currentlyEditingField', () => {
|
||||
const initialStateWithForm = {
|
||||
...initialState,
|
||||
currentlyEditingField: 'bioForm',
|
||||
drafts: { bio: 'New bio draft' },
|
||||
};
|
||||
const action = {
|
||||
type: CLOSE_FORM,
|
||||
payload: { formId: 'nameForm' },
|
||||
};
|
||||
expect(profilePage(initialStateWithForm, action)).toEqual(initialStateWithForm);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,13 +22,12 @@ import {
|
||||
resetDrafts,
|
||||
saveProfileBegin,
|
||||
saveProfileFailure,
|
||||
saveProfilePhotoBegin,
|
||||
saveProfilePhotoFailure,
|
||||
saveProfilePhotoReset,
|
||||
saveProfilePhotoSuccess,
|
||||
saveProfileReset,
|
||||
saveProfileSuccess,
|
||||
SAVE_PROFILE,
|
||||
saveProfilePhotoBegin,
|
||||
saveProfilePhotoReset,
|
||||
saveProfilePhotoSuccess,
|
||||
SAVE_PROFILE_PHOTO,
|
||||
} from './actions';
|
||||
import { handleSaveProfileSelector, userAccountSelector } from './selectors';
|
||||
@@ -38,38 +37,32 @@ export function* handleFetchProfile(action) {
|
||||
const { username } = action.payload;
|
||||
const userAccount = yield select(userAccountSelector);
|
||||
const isAuthenticatedUserProfile = username === getAuthenticatedUser().username;
|
||||
// Default our data assuming the account is the current user's account.
|
||||
let preferences = {};
|
||||
let account = userAccount;
|
||||
let courseCertificates = null;
|
||||
let countriesCodesList = [];
|
||||
|
||||
try {
|
||||
yield put(fetchProfileBegin());
|
||||
|
||||
// Depending on which profile we're loading, we need to make different calls.
|
||||
const calls = [
|
||||
call(ProfileApiService.getAccount, username),
|
||||
call(ProfileApiService.getCourseCertificates, username),
|
||||
call(ProfileApiService.getCountryList),
|
||||
];
|
||||
|
||||
if (isAuthenticatedUserProfile) {
|
||||
// If the profile is for the current user, get their preferences.
|
||||
// We don't need them for other users.
|
||||
calls.push(call(ProfileApiService.getPreferences, username));
|
||||
}
|
||||
|
||||
// Make all the calls in parallel.
|
||||
const result = yield all(calls);
|
||||
|
||||
if (isAuthenticatedUserProfile) {
|
||||
[account, courseCertificates, preferences] = result;
|
||||
[account, courseCertificates, countriesCodesList, preferences] = result;
|
||||
} else {
|
||||
[account, courseCertificates] = result;
|
||||
[account, courseCertificates, countriesCodesList] = result;
|
||||
}
|
||||
|
||||
// Set initial visibility values for account
|
||||
// Set account_privacy as custom is necessary so that when viewing another user's profile,
|
||||
// their full name is displayed and change visibility forms are worked correctly
|
||||
if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') {
|
||||
yield call(ProfileApiService.patchPreferences, action.payload.username, {
|
||||
account_privacy: 'custom',
|
||||
@@ -90,6 +83,7 @@ export function* handleFetchProfile(action) {
|
||||
preferences,
|
||||
courseCertificates,
|
||||
isAuthenticatedUserProfile,
|
||||
countriesCodesList,
|
||||
));
|
||||
|
||||
yield put(fetchProfileReset());
|
||||
@@ -108,7 +102,6 @@ export function* handleSaveProfile(action) {
|
||||
|
||||
const accountDrafts = pick(drafts, [
|
||||
'bio',
|
||||
'courseCertificates',
|
||||
'country',
|
||||
'levelOfEducation',
|
||||
'languageProficiencies',
|
||||
@@ -118,7 +111,6 @@ export function* handleSaveProfile(action) {
|
||||
|
||||
const preferencesDrafts = pick(drafts, [
|
||||
'visibilityBio',
|
||||
'visibilityCourseCertificates',
|
||||
'visibilityCountry',
|
||||
'visibilityLevelOfEducation',
|
||||
'visibilityLanguageProficiencies',
|
||||
@@ -132,7 +124,6 @@ export function* handleSaveProfile(action) {
|
||||
|
||||
yield put(saveProfileBegin());
|
||||
let accountResult = null;
|
||||
// Build the visibility drafts into a structure the API expects.
|
||||
|
||||
if (Object.keys(accountDrafts).length > 0) {
|
||||
accountResult = yield call(
|
||||
@@ -142,17 +133,14 @@ export function* handleSaveProfile(action) {
|
||||
);
|
||||
}
|
||||
|
||||
let preferencesResult = preferences; // assume it hasn't changed.
|
||||
let preferencesResult = preferences;
|
||||
if (Object.keys(preferencesDrafts).length > 0) {
|
||||
yield call(ProfileApiService.patchPreferences, action.payload.username, preferencesDrafts);
|
||||
// TODO: Temporary deoptimization since the patchPreferences call doesn't return anything.
|
||||
// Remove this second call once we can get a result from the one above.
|
||||
|
||||
preferencesResult = yield call(ProfileApiService.getPreferences, action.payload.username);
|
||||
}
|
||||
|
||||
// The account result is returned from the server.
|
||||
// The preferences draft is valid if the server didn't complain, so
|
||||
// pass it through directly.
|
||||
yield put(saveProfileSuccess(accountResult, preferencesResult));
|
||||
yield delay(1000);
|
||||
yield put(closeForm(action.payload.formId));
|
||||
@@ -178,12 +166,7 @@ export function* handleSaveProfilePhoto(action) {
|
||||
yield put(saveProfilePhotoSuccess(photoResult));
|
||||
yield put(saveProfilePhotoReset());
|
||||
} catch (e) {
|
||||
if (e.processedData) {
|
||||
yield put(saveProfilePhotoFailure(e.processedData));
|
||||
} else {
|
||||
yield put(saveProfilePhotoReset());
|
||||
throw e;
|
||||
}
|
||||
yield put(saveProfilePhotoReset());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +180,6 @@ export function* handleDeleteProfilePhoto(action) {
|
||||
yield put(deleteProfilePhotoReset());
|
||||
} catch (e) {
|
||||
yield put(deleteProfilePhotoReset());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ jest.mock('./services', () => ({
|
||||
getPreferences: jest.fn(),
|
||||
getAccount: jest.fn(),
|
||||
getCourseCertificates: jest.fn(),
|
||||
getCountryList: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedUser: jest.fn(),
|
||||
}));
|
||||
|
||||
// RootSaga and ProfileApiService must be imported AFTER the mock above.
|
||||
/* eslint-disable import/first */
|
||||
import profileSaga, {
|
||||
handleFetchProfile,
|
||||
@@ -68,17 +68,18 @@ describe('RootSaga', () => {
|
||||
const action = profileActions.fetchProfile('gonzo');
|
||||
const gen = handleFetchProfile(action);
|
||||
|
||||
const result = [userAccount, [1, 2, 3], { preferences: 'stuff' }];
|
||||
const result = [userAccount, [1, 2, 3], [], { preferences: 'stuff' }];
|
||||
|
||||
expect(gen.next().value).toEqual(select(userAccountSelector));
|
||||
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
|
||||
expect(gen.next().value).toEqual(all([
|
||||
call(ProfileApiService.getAccount, 'gonzo'),
|
||||
call(ProfileApiService.getCourseCertificates, 'gonzo'),
|
||||
call(ProfileApiService.getCountryList),
|
||||
call(ProfileApiService.getPreferences, 'gonzo'),
|
||||
]));
|
||||
expect(gen.next(result).value)
|
||||
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[2], result[1], true)));
|
||||
.toEqual(put(profileActions.fetchProfileSuccess(userAccount, result[3], result[1], true, [])));
|
||||
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
|
||||
expect(gen.next().value).toBeUndefined();
|
||||
});
|
||||
@@ -88,6 +89,7 @@ describe('RootSaga', () => {
|
||||
username: 'gonzo',
|
||||
other: 'data',
|
||||
};
|
||||
const countriesCodesList = [{ code: 'AX' }, { code: 'AL' }];
|
||||
getAuthenticatedUser.mockReturnValue(userAccount);
|
||||
const selectorData = {
|
||||
userAccount,
|
||||
@@ -96,16 +98,17 @@ describe('RootSaga', () => {
|
||||
const action = profileActions.fetchProfile('booyah');
|
||||
const gen = handleFetchProfile(action);
|
||||
|
||||
const result = [{}, [1, 2, 3]];
|
||||
const result = [{}, [1, 2, 3], countriesCodesList];
|
||||
|
||||
expect(gen.next().value).toEqual(select(userAccountSelector));
|
||||
expect(gen.next(selectorData).value).toEqual(put(profileActions.fetchProfileBegin()));
|
||||
expect(gen.next().value).toEqual(all([
|
||||
call(ProfileApiService.getAccount, 'booyah'),
|
||||
call(ProfileApiService.getCourseCertificates, 'booyah'),
|
||||
call(ProfileApiService.getCountryList),
|
||||
]));
|
||||
expect(gen.next(result).value)
|
||||
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false)));
|
||||
.toEqual(put(profileActions.fetchProfileSuccess(result[0], {}, result[1], false, countriesCodesList)));
|
||||
expect(gen.next().value).toEqual(put(profileActions.fetchProfileReset()));
|
||||
expect(gen.next().value).toBeUndefined();
|
||||
});
|
||||
@@ -132,8 +135,6 @@ describe('RootSaga', () => {
|
||||
expect(gen.next().value).toEqual(call(ProfileApiService.patchProfile, 'my username', {
|
||||
name: 'Full Name',
|
||||
}));
|
||||
// The library would supply the result of the above call
|
||||
// as the parameter to the NEXT yield. Here:
|
||||
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {})));
|
||||
expect(gen.next().value).toEqual(delay(1000));
|
||||
expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id')));
|
||||
@@ -162,5 +163,67 @@ describe('RootSaga', () => {
|
||||
expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' })));
|
||||
expect(gen.next().value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reset profile if error has no processedData', () => {
|
||||
const action = profileActions.saveProfile('formid', 'user1');
|
||||
const gen = handleSaveProfile(action);
|
||||
|
||||
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
|
||||
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
|
||||
|
||||
const err = new Error('oops');
|
||||
const result = gen.throw(err);
|
||||
expect(result.value).toEqual(put(profileActions.saveProfileReset()));
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSaveProfilePhoto', () => {
|
||||
it('should save profile photo successfully', () => {
|
||||
const action = profileActions.saveProfilePhoto('user1', { some: 'formdata' });
|
||||
const gen = handleSaveProfilePhoto(action);
|
||||
const fakePhoto = { url: 'photo.jpg' };
|
||||
|
||||
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
|
||||
expect(gen.next().value).toEqual(call(ProfileApiService.postProfilePhoto, 'user1', { some: 'formdata' }));
|
||||
expect(gen.next(fakePhoto).value).toEqual(put(profileActions.saveProfilePhotoSuccess(fakePhoto)));
|
||||
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoReset()));
|
||||
expect(gen.next().value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reset photo state on error', () => {
|
||||
const action = profileActions.saveProfilePhoto('user1', {});
|
||||
const gen = handleSaveProfilePhoto(action);
|
||||
|
||||
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
|
||||
|
||||
const err = new Error('fail');
|
||||
|
||||
expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset()));
|
||||
expect(gen.next().done).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeleteProfilePhoto', () => {
|
||||
it('should delete profile photo successfully', () => {
|
||||
const action = profileActions.deleteProfilePhoto('user1');
|
||||
const gen = handleDeleteProfilePhoto(action);
|
||||
const fakeResult = { ok: true };
|
||||
|
||||
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoBegin()));
|
||||
expect(gen.next().value).toEqual(call(ProfileApiService.deleteProfilePhoto, 'user1'));
|
||||
expect(gen.next(fakeResult).value).toEqual(put(profileActions.deleteProfilePhotoSuccess(fakeResult)));
|
||||
expect(gen.next().value).toEqual(put(profileActions.deleteProfilePhotoReset()));
|
||||
expect(gen.next().value).toBeUndefined();
|
||||
});
|
||||
it('should reset photo state on error', () => {
|
||||
const action = profileActions.saveProfilePhoto('user1', {});
|
||||
const gen = handleSaveProfilePhoto(action);
|
||||
|
||||
expect(gen.next().value).toEqual(put(profileActions.saveProfilePhotoBegin()));
|
||||
const err = new Error('fail');
|
||||
expect(gen.throw(err).value).toEqual(put(profileActions.saveProfilePhotoReset()));
|
||||
|
||||
expect(gen.next().done).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,24 +5,22 @@ import {
|
||||
getCountryList,
|
||||
getCountryMessages,
|
||||
getLanguageMessages,
|
||||
} from '@edx/frontend-platform/i18n'; // eslint-disable-line
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
|
||||
export const formIdSelector = (state, props) => props.formId;
|
||||
export const userAccountSelector = state => state.userAccount;
|
||||
|
||||
export const profileAccountSelector = state => state.profilePage.account;
|
||||
export const profileDraftsSelector = state => state.profilePage.drafts;
|
||||
export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy;
|
||||
export const profilePreferencesSelector = state => state.profilePage.preferences;
|
||||
export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates;
|
||||
export const profileAccountDraftsSelector = state => state.profilePage.accountDrafts;
|
||||
export const profileVisibilityDraftsSelector = state => state.profilePage.visibilityDrafts;
|
||||
export const saveStateSelector = state => state.profilePage.saveState;
|
||||
export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
|
||||
export const isLoadingProfileSelector = state => state.profilePage.isLoadingProfile;
|
||||
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
|
||||
export const accountErrorsSelector = state => state.profilePage.errors;
|
||||
export const isAuthenticatedUserProfileSelector = state => state.profilePage.isAuthenticatedUserProfile;
|
||||
export const countriesCodesListSelector = state => state.profilePage.countriesCodesList;
|
||||
|
||||
export const editableFormModeSelector = createSelector(
|
||||
profileAccountSelector,
|
||||
@@ -31,22 +29,11 @@ export const editableFormModeSelector = createSelector(
|
||||
formIdSelector,
|
||||
currentlyEditingFieldSelector,
|
||||
(account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => {
|
||||
// If the prop doesn't exist, that means it hasn't been set (for the current user's profile)
|
||||
// or is being hidden from us (for other users' profiles)
|
||||
let propExists = account[formId] != null && account[formId].length > 0;
|
||||
propExists = formId === 'certificates' ? certificates.length > 0 : propExists; // overwrite for certificates
|
||||
// If this isn't the current user's profile
|
||||
propExists = formId === 'certificates' ? certificates.length > 0 : propExists;
|
||||
if (!isAuthenticatedUserProfile) {
|
||||
return 'static';
|
||||
}
|
||||
// the current user has no age set / under 13 ...
|
||||
if (account.requiresParentalConsent) {
|
||||
// then there are only two options: static or nothing.
|
||||
// We use 'null' as a return value because the consumers of
|
||||
// getMode render nothing at all on a mode of null.
|
||||
return propExists ? 'static' : null;
|
||||
}
|
||||
// Otherwise, if this is the current user's profile...
|
||||
if (formId === currentlyEditingField) {
|
||||
return 'editing';
|
||||
}
|
||||
@@ -67,12 +54,10 @@ export const accountDraftsFieldSelector = createSelector(
|
||||
|
||||
export const visibilityDraftsFieldSelector = createSelector(
|
||||
formIdSelector,
|
||||
profileVisibilityDraftsSelector,
|
||||
(formId, visibilityDrafts) => visibilityDrafts[formId],
|
||||
profileDraftsSelector,
|
||||
(formId, drafts) => drafts[`visibility${formId.charAt(0).toUpperCase() + formId.slice(1)}`],
|
||||
);
|
||||
|
||||
// Note: Error messages are delivered from the server
|
||||
// localized according to a user's account settings
|
||||
export const formErrorSelector = createSelector(
|
||||
accountErrorsSelector,
|
||||
formIdSelector,
|
||||
@@ -90,11 +75,6 @@ export const editableFormSelector = createSelector(
|
||||
}),
|
||||
);
|
||||
|
||||
// Because this selector has no input selectors, it will only be evaluated once. This is fine
|
||||
// for now because we don't allow users to change the locale after page load.
|
||||
// Once we DO allow this, we should create an actual action which dispatches the locale into redux,
|
||||
// then we can modify this to get the locale from state rather than from getLocale() directly.
|
||||
// Once we do that, this will work as expected and be re-evaluated when the locale changes.
|
||||
export const localeSelector = () => getLocale();
|
||||
export const countryMessagesSelector = createSelector(
|
||||
localeSelector,
|
||||
@@ -112,7 +92,14 @@ export const sortedLanguagesSelector = createSelector(
|
||||
|
||||
export const sortedCountriesSelector = createSelector(
|
||||
localeSelector,
|
||||
locale => getCountryList(locale),
|
||||
countriesCodesListSelector,
|
||||
profileAccountSelector,
|
||||
(locale, countriesCodesList, profileAccount) => {
|
||||
const countryList = getCountryList(locale);
|
||||
const userCountry = profileAccount.country;
|
||||
|
||||
return countryList.filter(({ code }) => code === userCountry || countriesCodesList.find(x => x === code));
|
||||
},
|
||||
);
|
||||
|
||||
export const preferredLanguageSelector = createSelector(
|
||||
@@ -130,10 +117,14 @@ export const countrySelector = createSelector(
|
||||
editableFormSelector,
|
||||
sortedCountriesSelector,
|
||||
countryMessagesSelector,
|
||||
(editableForm, sortedCountries, countryMessages) => ({
|
||||
countriesCodesListSelector,
|
||||
profileAccountSelector,
|
||||
(editableForm, translatedCountries, countryMessages, countriesCodesList, account) => ({
|
||||
...editableForm,
|
||||
sortedCountries,
|
||||
translatedCountries,
|
||||
countryMessages,
|
||||
countriesCodesList,
|
||||
committedCountry: account.country,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -157,9 +148,6 @@ export const profileImageSelector = createSelector(
|
||||
: {}),
|
||||
);
|
||||
|
||||
/**
|
||||
* This is used by a saga to pull out data to process.
|
||||
*/
|
||||
export const handleSaveProfileSelector = createSelector(
|
||||
profileDraftsSelector,
|
||||
profilePreferencesSelector,
|
||||
@@ -169,7 +157,6 @@ export const handleSaveProfileSelector = createSelector(
|
||||
}),
|
||||
);
|
||||
|
||||
// Reformats the social links in a platform-keyed hash.
|
||||
const socialLinksByPlatformSelector = createSelector(
|
||||
profileAccountSelector,
|
||||
(account) => {
|
||||
@@ -196,24 +183,18 @@ const draftSocialLinksByPlatformSelector = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
// Fleshes out our list of existing social links with all the other ones the user can set.
|
||||
export const formSocialLinksSelector = createSelector(
|
||||
socialLinksByPlatformSelector,
|
||||
draftSocialLinksByPlatformSelector,
|
||||
(linksByPlatform, draftLinksByPlatform) => {
|
||||
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
|
||||
const knownPlatforms = ['x', 'facebook', 'linkedin'];
|
||||
const socialLinks = [];
|
||||
// For each known platform
|
||||
knownPlatforms.forEach((platform) => {
|
||||
// If the link is in our drafts.
|
||||
if (draftLinksByPlatform[platform] !== undefined) {
|
||||
// Use the draft one.
|
||||
socialLinks.push(draftLinksByPlatform[platform]);
|
||||
} else if (linksByPlatform[platform] !== undefined) {
|
||||
// Otherwise use the real one.
|
||||
socialLinks.push(linksByPlatform[platform]);
|
||||
} else {
|
||||
// And if it's not in either, use a stub.
|
||||
socialLinks.push({
|
||||
platform,
|
||||
socialLink: null,
|
||||
@@ -232,7 +213,6 @@ export const visibilitiesSelector = createSelector(
|
||||
case 'custom':
|
||||
return {
|
||||
visibilityBio: preferences.visibilityBio || 'all_users',
|
||||
visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'all_users',
|
||||
visibilityCountry: preferences.visibilityCountry || 'all_users',
|
||||
visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users',
|
||||
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users',
|
||||
@@ -242,7 +222,6 @@ export const visibilitiesSelector = createSelector(
|
||||
case 'private':
|
||||
return {
|
||||
visibilityBio: 'private',
|
||||
visibilityCourseCertificates: 'private',
|
||||
visibilityCountry: 'private',
|
||||
visibilityLevelOfEducation: 'private',
|
||||
visibilityLanguageProficiencies: 'private',
|
||||
@@ -251,13 +230,8 @@ export const visibilitiesSelector = createSelector(
|
||||
};
|
||||
case 'all_users':
|
||||
default:
|
||||
// All users is intended to fall through to default.
|
||||
// If there is no value for accountPrivacy in perferences, that means it has not been
|
||||
// explicitly set yet. The server assumes - today - that this means "all_users",
|
||||
// so we emulate that here in the client.
|
||||
return {
|
||||
visibilityBio: 'all_users',
|
||||
visibilityCourseCertificates: 'all_users',
|
||||
visibilityCountry: 'all_users',
|
||||
visibilityLevelOfEducation: 'all_users',
|
||||
visibilityLanguageProficiencies: 'all_users',
|
||||
@@ -268,9 +242,6 @@ export const visibilitiesSelector = createSelector(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* If there's no draft present at all (undefined), use the original committed value.
|
||||
*/
|
||||
function chooseFormValue(draft, committed) {
|
||||
return draft !== undefined ? draft : committed;
|
||||
}
|
||||
@@ -285,10 +256,6 @@ export const formValuesSelector = createSelector(
|
||||
bio: chooseFormValue(drafts.bio, account.bio),
|
||||
visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio),
|
||||
courseCertificates,
|
||||
visibilityCourseCertificates: chooseFormValue(
|
||||
drafts.visibilityCourseCertificates,
|
||||
visibilities.visibilityCourseCertificates,
|
||||
),
|
||||
country: chooseFormValue(drafts.country, account.country),
|
||||
visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry),
|
||||
levelOfEducation: chooseFormValue(drafts.levelOfEducation, account.levelOfEducation),
|
||||
@@ -306,7 +273,7 @@ export const formValuesSelector = createSelector(
|
||||
),
|
||||
name: chooseFormValue(drafts.name, account.name),
|
||||
visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName),
|
||||
socialLinks, // Social links is calculated in its own selector, since it's complicated.
|
||||
socialLinks,
|
||||
visibilitySocialLinks: chooseFormValue(
|
||||
drafts.visibilitySocialLinks,
|
||||
visibilities.visibilitySocialLinks,
|
||||
@@ -323,6 +290,7 @@ export const profilePageSelector = createSelector(
|
||||
isLoadingProfileSelector,
|
||||
draftSocialLinksByPlatformSelector,
|
||||
accountErrorsSelector,
|
||||
isAuthenticatedUserProfileSelector,
|
||||
(
|
||||
account,
|
||||
formValues,
|
||||
@@ -332,47 +300,39 @@ export const profilePageSelector = createSelector(
|
||||
isLoadingProfile,
|
||||
draftSocialLinksByPlatform,
|
||||
errors,
|
||||
isAuthenticatedUserProfile,
|
||||
) => ({
|
||||
// Account data we need
|
||||
username: account.username,
|
||||
profileImage,
|
||||
requiresParentalConsent: account.requiresParentalConsent,
|
||||
dateJoined: account.dateJoined,
|
||||
yearOfBirth: account.yearOfBirth,
|
||||
|
||||
// Bio form data
|
||||
bio: formValues.bio,
|
||||
visibilityBio: formValues.visibilityBio,
|
||||
|
||||
// Certificates form data
|
||||
courseCertificates: formValues.courseCertificates,
|
||||
visibilityCourseCertificates: formValues.visibilityCourseCertificates,
|
||||
|
||||
// Country form data
|
||||
country: formValues.country,
|
||||
visibilityCountry: formValues.visibilityCountry,
|
||||
|
||||
// Education form data
|
||||
levelOfEducation: formValues.levelOfEducation,
|
||||
visibilityLevelOfEducation: formValues.visibilityLevelOfEducation,
|
||||
|
||||
// Language proficiency form data
|
||||
languageProficiencies: formValues.languageProficiencies,
|
||||
visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies,
|
||||
|
||||
// Name form data
|
||||
name: formValues.name,
|
||||
visibilityName: formValues.visibilityName,
|
||||
|
||||
// Social links form data
|
||||
socialLinks: formValues.socialLinks,
|
||||
visibilitySocialLinks: formValues.visibilitySocialLinks,
|
||||
draftSocialLinksByPlatform,
|
||||
|
||||
// Other data we need
|
||||
saveState,
|
||||
savePhotoState,
|
||||
isLoadingProfile,
|
||||
photoUploadError: errors.photo || null,
|
||||
isAuthenticatedUserProfile,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,11 +2,24 @@ import { ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils';
|
||||
import { FIELD_LABELS } from './constants';
|
||||
|
||||
ensureConfig(['LMS_BASE_URL'], 'Profile API service');
|
||||
|
||||
function processAccountData(data) {
|
||||
return camelCaseObject(data);
|
||||
const processedData = camelCaseObject(data);
|
||||
return {
|
||||
...processedData,
|
||||
socialLinks: Array.isArray(processedData.socialLinks) ? processedData.socialLinks : [],
|
||||
languageProficiencies: Array.isArray(processedData.languageProficiencies)
|
||||
? processedData.languageProficiencies : [],
|
||||
name: processedData.name || null,
|
||||
bio: processedData.bio || null,
|
||||
country: processedData.country || null,
|
||||
levelOfEducation: processedData.levelOfEducation || null,
|
||||
profileImage: processedData.profileImage || {},
|
||||
yearOfBirth: processedData.yearOfBirth || null,
|
||||
};
|
||||
}
|
||||
|
||||
function processAndThrowError(error, errorDataProcessor) {
|
||||
@@ -19,15 +32,12 @@ function processAndThrowError(error, errorDataProcessor) {
|
||||
}
|
||||
}
|
||||
|
||||
// GET ACCOUNT
|
||||
export async function getAccount(username) {
|
||||
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}`);
|
||||
|
||||
// Process response data
|
||||
return processAccountData(data);
|
||||
}
|
||||
|
||||
// PATCH PROFILE
|
||||
export async function patchProfile(username, params) {
|
||||
const processedParams = snakeCaseObject(params);
|
||||
|
||||
@@ -41,12 +51,9 @@ export async function patchProfile(username, params) {
|
||||
processAndThrowError(error, processAccountData);
|
||||
});
|
||||
|
||||
// Process response data
|
||||
return processAccountData(data);
|
||||
}
|
||||
|
||||
// POST PROFILE PHOTO
|
||||
|
||||
export async function postProfilePhoto(username, formData) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { data } = await getHttpClient().post(
|
||||
@@ -70,8 +77,6 @@ export async function postProfilePhoto(username, formData) {
|
||||
return updatedData.profileImage;
|
||||
}
|
||||
|
||||
// DELETE PROFILE PHOTO
|
||||
|
||||
export async function deleteProfilePhoto(username) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { data } = await getHttpClient().delete(`${getConfig().LMS_BASE_URL}/api/user/v1/accounts/${username}/image`);
|
||||
@@ -85,14 +90,12 @@ export async function deleteProfilePhoto(username) {
|
||||
return updatedData.profileImage;
|
||||
}
|
||||
|
||||
// GET PREFERENCES
|
||||
export async function getPreferences(username) {
|
||||
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
// PATCH PREFERENCES
|
||||
export async function patchPreferences(username, params) {
|
||||
let processedParams = snakeCaseObject(params);
|
||||
processedParams = convertKeyNames(processedParams, {
|
||||
@@ -114,8 +117,6 @@ export async function patchPreferences(username, params) {
|
||||
return params; // TODO: Once the server returns the updated preferences object, return that.
|
||||
}
|
||||
|
||||
// GET COURSE CERTIFICATES
|
||||
|
||||
function transformCertificateData(data) {
|
||||
const transformedData = [];
|
||||
data.forEach((cert) => {
|
||||
@@ -147,3 +148,21 @@ export async function getCourseCertificates(username) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function extractCountryList(data) {
|
||||
return data?.fields
|
||||
.find(({ name }) => name === FIELD_LABELS.COUNTRY)
|
||||
?.options?.map(({ value }) => (value)) || [];
|
||||
}
|
||||
|
||||
export async function getCountryList() {
|
||||
const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
|
||||
|
||||
try {
|
||||
const { data } = await getHttpClient().get(url);
|
||||
return extractCountryList(data);
|
||||
} catch (e) {
|
||||
logError(e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
174
src/profile/data/services.test.js
Normal file
174
src/profile/data/services.test.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
getAccount,
|
||||
patchProfile,
|
||||
postProfilePhoto,
|
||||
deleteProfilePhoto,
|
||||
getPreferences,
|
||||
patchPreferences,
|
||||
getCourseCertificates,
|
||||
getCountryList,
|
||||
} from './services';
|
||||
|
||||
import { FIELD_LABELS } from './constants';
|
||||
|
||||
import { camelCaseObject, snakeCaseObject, convertKeyNames } from '../utils';
|
||||
|
||||
// --- Mocks ---
|
||||
jest.mock('@edx/frontend-platform', () => ({
|
||||
ensureConfig: jest.fn(),
|
||||
getConfig: jest.fn(() => ({ LMS_BASE_URL: 'http://fake-lms' })),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
camelCaseObject: jest.fn((obj) => obj),
|
||||
snakeCaseObject: jest.fn((obj) => obj),
|
||||
convertKeyNames: jest.fn((obj) => obj),
|
||||
}));
|
||||
|
||||
const mockHttpClient = {
|
||||
get: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
post: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
|
||||
});
|
||||
|
||||
// --- Tests ---
|
||||
describe('services', () => {
|
||||
describe('getAccount', () => {
|
||||
it('should return processed account data', async () => {
|
||||
const mockData = { name: 'John Doe', socialLinks: [] };
|
||||
mockHttpClient.get.mockResolvedValue({ data: mockData });
|
||||
|
||||
const result = await getAccount('john');
|
||||
expect(result).toMatchObject(mockData);
|
||||
expect(mockHttpClient.get).toHaveBeenCalledWith(
|
||||
'http://fake-lms/api/user/v1/accounts/john',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchProfile', () => {
|
||||
it('should patch and return processed data', async () => {
|
||||
const mockData = { bio: 'New Bio' };
|
||||
mockHttpClient.patch.mockResolvedValue({ data: mockData });
|
||||
|
||||
const result = await patchProfile('john', { bio: 'New Bio' });
|
||||
expect(result).toMatchObject(mockData);
|
||||
expect(snakeCaseObject).toHaveBeenCalledWith({ bio: 'New Bio' });
|
||||
});
|
||||
|
||||
it('should throw processed error on failure', async () => {
|
||||
const error = { response: { data: { some: 'error' } } };
|
||||
mockHttpClient.patch.mockRejectedValue(error);
|
||||
|
||||
await expect(patchProfile('john', {})).rejects.toMatchObject(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postProfilePhoto', () => {
|
||||
it('should post photo and return updated profile image', async () => {
|
||||
mockHttpClient.post.mockResolvedValue({});
|
||||
mockHttpClient.get.mockResolvedValue({
|
||||
data: { profileImage: { url: 'img.png' } },
|
||||
});
|
||||
|
||||
const result = await postProfilePhoto('john', new FormData());
|
||||
expect(result).toEqual({ url: 'img.png' });
|
||||
});
|
||||
|
||||
it('should throw error if API fails', async () => {
|
||||
const error = { response: { data: { error: 'fail' } } };
|
||||
mockHttpClient.post.mockRejectedValue(error);
|
||||
await expect(postProfilePhoto('john', new FormData())).rejects.toMatchObject(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteProfilePhoto', () => {
|
||||
it('should delete photo and return updated profile image', async () => {
|
||||
mockHttpClient.delete.mockResolvedValue({});
|
||||
mockHttpClient.get.mockResolvedValue({
|
||||
data: { profileImage: { url: 'deleted.png' } },
|
||||
});
|
||||
|
||||
const result = await deleteProfilePhoto('john');
|
||||
expect(result).toEqual({ url: 'deleted.png' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPreferences', () => {
|
||||
it('should return camelCased preferences', async () => {
|
||||
mockHttpClient.get.mockResolvedValue({ data: { pref: 1 } });
|
||||
|
||||
const result = await getPreferences('john');
|
||||
expect(result).toMatchObject({ pref: 1 });
|
||||
expect(camelCaseObject).toHaveBeenCalledWith({ pref: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchPreferences', () => {
|
||||
it('should patch preferences and return params', async () => {
|
||||
mockHttpClient.patch.mockResolvedValue({});
|
||||
const params = { visibility_bio: true };
|
||||
|
||||
const result = await patchPreferences('john', params);
|
||||
expect(result).toBe(params);
|
||||
expect(snakeCaseObject).toHaveBeenCalledWith(params);
|
||||
expect(convertKeyNames).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCourseCertificates', () => {
|
||||
it('should return transformed certificates', async () => {
|
||||
mockHttpClient.get.mockResolvedValue({
|
||||
data: [{ download_url: '/path', certificate_type: 'type' }],
|
||||
});
|
||||
|
||||
const result = await getCourseCertificates('john');
|
||||
expect(result[0]).toHaveProperty('downloadUrl', 'http://fake-lms/path');
|
||||
});
|
||||
|
||||
it('should log error and return empty array on failure', async () => {
|
||||
mockHttpClient.get.mockRejectedValue(new Error('fail'));
|
||||
const result = await getCourseCertificates('john');
|
||||
expect(result).toEqual([]);
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCountryList', () => {
|
||||
it('should extract country list', async () => {
|
||||
mockHttpClient.get.mockResolvedValue({
|
||||
data: {
|
||||
fields: [
|
||||
{ name: FIELD_LABELS.COUNTRY, options: [{ value: 'US' }, { value: 'CA' }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCountryList();
|
||||
expect(result).toEqual(['US', 'CA']);
|
||||
});
|
||||
|
||||
it('should log error and return empty array on failure', async () => {
|
||||
mockHttpClient.get.mockRejectedValue(new Error('fail'));
|
||||
const result = await getCountryList();
|
||||
expect(result).toEqual([]);
|
||||
expect(logError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,149 +1,140 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@edx/paragon';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@openedx/paragon';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import messages from './Bio.messages';
|
||||
|
||||
// Components
|
||||
import FormControls from './elements/FormControls';
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import EmptyContent from './elements/EmptyContent';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Selectors
|
||||
import { editableFormSelector } from '../data/selectors';
|
||||
import {
|
||||
useCloseOpenHandler,
|
||||
useHandleChange,
|
||||
useHandleSubmit,
|
||||
useIsOnMobileScreen,
|
||||
useIsVisibilityEnabled,
|
||||
} from '../data/hooks';
|
||||
|
||||
class Bio extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const Bio = ({
|
||||
formId,
|
||||
bio,
|
||||
visibilityBio,
|
||||
editMode,
|
||||
saveState,
|
||||
error,
|
||||
changeHandler,
|
||||
submitHandler,
|
||||
closeHandler,
|
||||
openHandler,
|
||||
}) => {
|
||||
const isMobileView = useIsOnMobileScreen();
|
||||
const isVisibilityEnabled = useIsVisibilityEnabled();
|
||||
const intl = useIntl();
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
}
|
||||
const handleChange = useHandleChange(changeHandler);
|
||||
const handleSubmit = useHandleSubmit(submitHandler, formId);
|
||||
const handleOpen = useCloseOpenHandler(openHandler, formId);
|
||||
const handleClose = useCloseOpenHandler(closeHandler, formId);
|
||||
|
||||
handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
this.props.changeHandler(name, value);
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.submitHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.props.closeHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.props.openHandler(this.props.formId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formId, bio, visibilityBio, editMode, saveState, error, intl,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-5"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
isInvalid={error !== null}
|
||||
>
|
||||
<label className="edit-section-header" htmlFor={formId}>
|
||||
{intl.formatMessage(messages['profile.bio.about.me'])}
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id={formId}
|
||||
name={formId}
|
||||
value={bio}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityBio"
|
||||
saveState={saveState}
|
||||
visibility={visibilityBio}
|
||||
cancelHandler={this.handleClose}
|
||||
changeHandler={this.handleChange}
|
||||
return (
|
||||
<SwitchContent
|
||||
className={classNames([
|
||||
isMobileView ? 'pt-40px' : 'pt-0',
|
||||
])}
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
className="m-0 pb-3"
|
||||
isInvalid={error !== null}
|
||||
>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
|
||||
{intl.formatMessage(messages['profile.bio.about.me'])}
|
||||
</p>
|
||||
<textarea
|
||||
className="form-control py-10px"
|
||||
id={formId}
|
||||
name={formId}
|
||||
value={bio}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.bio.about.me'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilityBio !== null}
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityBio"
|
||||
saveState={saveState}
|
||||
visibility={visibilityBio}
|
||||
cancelHandler={handleClose}
|
||||
changeHandler={handleChange}
|
||||
/>
|
||||
<p data-hj-suppress className="lead">{bio}</p>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.bio.about.me'])} />
|
||||
<EmptyContent onClick={this.handleOpen}>
|
||||
<FormattedMessage
|
||||
id="profile.bio.empty"
|
||||
defaultMessage="Add a short bio"
|
||||
description="instructions when the user hasn't written an About Me"
|
||||
/>
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.bio.about.me'])} />
|
||||
<p data-hj-suppress className="lead">{bio}</p>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.bio.about.me'])}
|
||||
</p>
|
||||
<EditableItemHeader
|
||||
content={bio}
|
||||
showEditButton
|
||||
onClickEdit={handleOpen}
|
||||
showVisibility={visibilityBio !== null && isVisibilityEnabled}
|
||||
visibility={visibilityBio}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.bio.about.me'])}
|
||||
</p>
|
||||
<EmptyContent onClick={handleOpen}>
|
||||
<FormattedMessage
|
||||
id="profile.bio.empty"
|
||||
defaultMessage="Add a short bio"
|
||||
description="instructions when the user hasn't written an About Me"
|
||||
/>
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.bio.about.me'])}
|
||||
</p>
|
||||
<EditableItemHeader content={bio} />
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Bio.propTypes = {
|
||||
// It'd be nice to just set this as a defaultProps...
|
||||
// except the class that comes out on the other side of react-redux's
|
||||
// connect() method won't have it anymore. Static properties won't survive
|
||||
// through the higher order function.
|
||||
formId: PropTypes.string.isRequired,
|
||||
|
||||
// From Selector
|
||||
bio: PropTypes.string,
|
||||
visibilityBio: PropTypes.oneOf(['private', 'all_users']),
|
||||
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
|
||||
saveState: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
|
||||
// Actions
|
||||
changeHandler: PropTypes.func.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
closeHandler: PropTypes.func.isRequired,
|
||||
openHandler: PropTypes.func.isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Bio.defaultProps = {
|
||||
@@ -157,4 +148,4 @@ Bio.defaultProps = {
|
||||
export default connect(
|
||||
editableFormSelector,
|
||||
{},
|
||||
)(injectIntl(Bio));
|
||||
)(Bio);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
'profile.bio.about.me': {
|
||||
id: 'profile.bio.about.me',
|
||||
defaultMessage: 'About Me',
|
||||
defaultMessage: 'Bio',
|
||||
description: 'A section of a user profile',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate, FormattedMessage, injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Hyperlink } from '@edx/paragon';
|
||||
import { connect } from 'react-redux';
|
||||
import get from 'lodash.get';
|
||||
|
||||
import messages from './Certificates.messages';
|
||||
|
||||
// Components
|
||||
import FormControls from './elements/FormControls';
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Assets
|
||||
import professionalCertificateSVG from '../assets/professional-certificate.svg';
|
||||
import verifiedCertificateSVG from '../assets/verified-certificate.svg';
|
||||
|
||||
// Selectors
|
||||
import { certificatesSelector } from '../data/selectors';
|
||||
|
||||
class Certificates extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
this.props.changeHandler(name, value);
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.submitHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.props.closeHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.props.openHandler(this.props.formId);
|
||||
}
|
||||
|
||||
renderCertificate({
|
||||
certificateType, courseDisplayName, courseOrganization, modifiedDate, downloadUrl, courseId,
|
||||
}) {
|
||||
const { intl } = this.props;
|
||||
const certificateIllustration = (() => {
|
||||
switch (certificateType) {
|
||||
case 'professional':
|
||||
case 'no-id-professional':
|
||||
return professionalCertificateSVG;
|
||||
case 'verified':
|
||||
return verifiedCertificateSVG;
|
||||
case 'honor':
|
||||
case 'audit':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div key={`${modifiedDate}-${courseId}`} className="col col-sm-6 d-flex align-items-stretch">
|
||||
<div className="card mb-4 certificate flex-grow-1">
|
||||
<div
|
||||
className="certificate-type-illustration"
|
||||
style={{ backgroundImage: `url(${certificateIllustration})` }}
|
||||
/>
|
||||
<div className="card-body d-flex flex-column">
|
||||
<div className="card-title">
|
||||
<p className="small mb-0">
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.certificates.types.${certificateType}`,
|
||||
messages['profile.certificates.types.unknown'],
|
||||
))}
|
||||
</p>
|
||||
<h4 className="certificate-title">{courseDisplayName}</h4>
|
||||
</div>
|
||||
<p className="small mb-0">
|
||||
<FormattedMessage
|
||||
id="profile.certificate.organization.label"
|
||||
defaultMessage="From"
|
||||
/>
|
||||
</p>
|
||||
<p className="h6 mb-4">{courseOrganization}</p>
|
||||
<div className="flex-grow-1" />
|
||||
<p className="small mb-2">
|
||||
<FormattedMessage
|
||||
id="profile.certificate.completion.date.label"
|
||||
defaultMessage="Completed on {date}"
|
||||
values={{
|
||||
date: <FormattedDate value={new Date(modifiedDate)} />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<div>
|
||||
<Hyperlink destination={downloadUrl} className="btn btn-outline-primary" target="_blank">
|
||||
{intl.formatMessage(messages['profile.certificates.view.certificate'])}
|
||||
</Hyperlink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderCertificates() {
|
||||
if (this.props.certificates === null || this.props.certificates.length === 0) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id="profile.no.certificates"
|
||||
defaultMessage="You don't have any certificates yet."
|
||||
description="displays when user has no course completion certificates"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row align-items-stretch">{this.props.certificates.map(certificate => this.renderCertificate(certificate))}</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
visibilityCourseCertificates, editMode, saveState, intl,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-4"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby="course-certificates-label">
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<EditableItemHeader
|
||||
headingId="course-certificates-label"
|
||||
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
|
||||
/>
|
||||
<FormControls
|
||||
visibilityId="visibilityCourseCertificates"
|
||||
saveState={saveState}
|
||||
visibility={visibilityCourseCertificates}
|
||||
cancelHandler={this.handleClose}
|
||||
changeHandler={this.handleChange}
|
||||
/>
|
||||
{this.renderCertificates()}
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilityCourseCertificates !== null}
|
||||
visibility={visibilityCourseCertificates}
|
||||
/>
|
||||
{this.renderCertificates()}
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilityCourseCertificates !== null}
|
||||
visibility={visibilityCourseCertificates}
|
||||
/>
|
||||
{this.renderCertificates()}
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.certificates.my.certificates'])} />
|
||||
{this.renderCertificates()}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Certificates.propTypes = {
|
||||
// It'd be nice to just set this as a defaultProps...
|
||||
// except the class that comes out on the other side of react-redux's
|
||||
// connect() method won't have it anymore. Static properties won't survive
|
||||
// through the higher order function.
|
||||
formId: PropTypes.string.isRequired,
|
||||
|
||||
// From Selector
|
||||
certificates: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string,
|
||||
})),
|
||||
visibilityCourseCertificates: PropTypes.oneOf(['private', 'all_users']),
|
||||
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
|
||||
saveState: PropTypes.string,
|
||||
|
||||
// Actions
|
||||
changeHandler: PropTypes.func.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
closeHandler: PropTypes.func.isRequired,
|
||||
openHandler: PropTypes.func.isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Certificates.defaultProps = {
|
||||
editMode: 'static',
|
||||
saveState: null,
|
||||
visibilityCourseCertificates: 'private',
|
||||
certificates: null,
|
||||
};
|
||||
|
||||
export default connect(
|
||||
certificatesSelector,
|
||||
{},
|
||||
)(injectIntl(Certificates));
|
||||
@@ -1,172 +1,152 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@openedx/paragon';
|
||||
|
||||
import messages from './Country.messages';
|
||||
|
||||
// Components
|
||||
import FormControls from './elements/FormControls';
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import EmptyContent from './elements/EmptyContent';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Selectors
|
||||
import { countrySelector } from '../data/selectors';
|
||||
import {
|
||||
useCloseOpenHandler,
|
||||
useHandleChange,
|
||||
useHandleSubmit,
|
||||
useIsVisibilityEnabled,
|
||||
} from '../data/hooks';
|
||||
|
||||
class Country extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const Country = ({
|
||||
formId,
|
||||
country,
|
||||
visibilityCountry,
|
||||
editMode,
|
||||
saveState,
|
||||
error,
|
||||
translatedCountries,
|
||||
countriesCodesList,
|
||||
countryMessages,
|
||||
changeHandler,
|
||||
submitHandler,
|
||||
closeHandler,
|
||||
openHandler,
|
||||
}) => {
|
||||
const isVisibilityEnabled = useIsVisibilityEnabled();
|
||||
const intl = useIntl();
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
}
|
||||
const handleChange = useHandleChange(changeHandler);
|
||||
const handleSubmit = useHandleSubmit(submitHandler, formId);
|
||||
const handleOpen = useCloseOpenHandler(openHandler, formId);
|
||||
const handleClose = useCloseOpenHandler(closeHandler, formId);
|
||||
|
||||
handleChange(e) {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
} = e.target;
|
||||
this.props.changeHandler(name, value);
|
||||
}
|
||||
const isDisabledCountry = (countryCode) => countriesCodesList.length > 0
|
||||
&& !countriesCodesList.find(code => code === countryCode);
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.submitHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.props.closeHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.props.openHandler(this.props.formId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formId,
|
||||
country,
|
||||
visibilityCountry,
|
||||
editMode,
|
||||
saveState,
|
||||
error,
|
||||
intl,
|
||||
sortedCountries,
|
||||
countryMessages,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-5"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
isInvalid={error !== null}
|
||||
return (
|
||||
<SwitchContent
|
||||
className="pt-40px"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
className="m-0 pb-3"
|
||||
isInvalid={error !== null}
|
||||
>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
|
||||
{intl.formatMessage(messages['profile.country.label'])}
|
||||
</p>
|
||||
<select
|
||||
data-hj-suppress
|
||||
className="form-control py-10px"
|
||||
type="select"
|
||||
id={formId}
|
||||
name={formId}
|
||||
value={country}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<label className="edit-section-header" htmlFor={formId}>
|
||||
{intl.formatMessage(messages['profile.country.label'])}
|
||||
</label>
|
||||
<select
|
||||
data-hj-suppress
|
||||
className="form-control"
|
||||
type="select"
|
||||
id={formId}
|
||||
name={formId}
|
||||
value={country}
|
||||
onChange={this.handleChange}
|
||||
>
|
||||
<option value=""> </option>
|
||||
{sortedCountries.map(({ code, name }) => (
|
||||
<option key={code} value={code}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityCountry"
|
||||
saveState={saveState}
|
||||
visibility={visibilityCountry}
|
||||
cancelHandler={this.handleClose}
|
||||
changeHandler={this.handleChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.country.label'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilityCountry !== null}
|
||||
<option value=""> </option>
|
||||
{translatedCountries.map(({ code, name }) => (
|
||||
<option key={code} value={code} disabled={isDisabledCountry(code)}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityCountry"
|
||||
saveState={saveState}
|
||||
visibility={visibilityCountry}
|
||||
cancelHandler={handleClose}
|
||||
changeHandler={handleChange}
|
||||
/>
|
||||
<p data-hj-suppress className="h5">{countryMessages[country]}</p>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.country.label'])}
|
||||
/>
|
||||
<EmptyContent onClick={this.handleOpen}>
|
||||
{intl.formatMessage(messages['profile.country.empty'])}
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.country.label'])}
|
||||
/>
|
||||
<p data-hj-suppress className="h5">{countryMessages[country]}</p>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.country.label'])}
|
||||
</p>
|
||||
<EditableItemHeader
|
||||
content={countryMessages[country]}
|
||||
showEditButton
|
||||
onClickEdit={handleOpen}
|
||||
showVisibility={visibilityCountry !== null && isVisibilityEnabled}
|
||||
visibility={visibilityCountry}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.country.label'])}
|
||||
</p>
|
||||
<EmptyContent onClick={handleOpen}>
|
||||
{intl.formatMessage(messages['profile.country.empty'])}
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.country.label'])}
|
||||
</p>
|
||||
<EditableItemHeader content={countryMessages[country]} />
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Country.propTypes = {
|
||||
// It'd be nice to just set this as a defaultProps...
|
||||
// except the class that comes out on the other side of react-redux's
|
||||
// connect() method won't have it anymore. Static properties won't survive
|
||||
// through the higher order function.
|
||||
formId: PropTypes.string.isRequired,
|
||||
|
||||
// From Selector
|
||||
country: PropTypes.string,
|
||||
visibilityCountry: PropTypes.oneOf(['private', 'all_users']),
|
||||
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
|
||||
saveState: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
sortedCountries: PropTypes.arrayOf(PropTypes.shape({
|
||||
translatedCountries: PropTypes.arrayOf(PropTypes.shape({
|
||||
code: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
countriesCodesList: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
countryMessages: PropTypes.objectOf(PropTypes.string).isRequired,
|
||||
|
||||
// Actions
|
||||
changeHandler: PropTypes.func.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
closeHandler: PropTypes.func.isRequired,
|
||||
openHandler: PropTypes.func.isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Country.defaultProps = {
|
||||
@@ -180,4 +160,4 @@ Country.defaultProps = {
|
||||
export default connect(
|
||||
countrySelector,
|
||||
{},
|
||||
)(injectIntl(Country));
|
||||
)(Country);
|
||||
|
||||
@@ -3,12 +3,12 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
'profile.country.label': {
|
||||
id: 'profile.country.label',
|
||||
defaultMessage: 'Location',
|
||||
defaultMessage: 'Country',
|
||||
description: 'The label for a country in a user profile.',
|
||||
},
|
||||
'profile.country.empty': {
|
||||
id: 'profile.country.empty',
|
||||
defaultMessage: 'Add location',
|
||||
defaultMessage: 'Add country',
|
||||
description: 'The affordance to add country location to a user’s profile.',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,180 +1,160 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import get from 'lodash.get';
|
||||
import { Form } from '@edx/paragon';
|
||||
import { Form } from '@openedx/paragon';
|
||||
|
||||
import messages from './Education.messages';
|
||||
|
||||
// Components
|
||||
import FormControls from './elements/FormControls';
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import EmptyContent from './elements/EmptyContent';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Constants
|
||||
import { EDUCATION_LEVELS } from '../data/constants';
|
||||
|
||||
// Selectors
|
||||
import { editableFormSelector } from '../data/selectors';
|
||||
import {
|
||||
useCloseOpenHandler,
|
||||
useHandleChange,
|
||||
useHandleSubmit,
|
||||
useIsVisibilityEnabled,
|
||||
} from '../data/hooks';
|
||||
|
||||
class Education extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const Education = ({
|
||||
formId,
|
||||
levelOfEducation,
|
||||
visibilityLevelOfEducation,
|
||||
editMode,
|
||||
saveState,
|
||||
error,
|
||||
changeHandler,
|
||||
submitHandler,
|
||||
closeHandler,
|
||||
openHandler,
|
||||
}) => {
|
||||
const isVisibilityEnabled = useIsVisibilityEnabled();
|
||||
const intl = useIntl();
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
}
|
||||
const handleChange = useHandleChange(changeHandler);
|
||||
const handleSubmit = useHandleSubmit(submitHandler, formId);
|
||||
const handleOpen = useCloseOpenHandler(openHandler, formId);
|
||||
const handleClose = useCloseOpenHandler(closeHandler, formId);
|
||||
|
||||
handleChange(e) {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
} = e.target;
|
||||
this.props.changeHandler(name, value);
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.submitHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.props.closeHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.props.openHandler(this.props.formId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formId, levelOfEducation, visibilityLevelOfEducation, editMode, saveState, error, intl,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-5"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
isInvalid={error !== null}
|
||||
return (
|
||||
<SwitchContent
|
||||
className="pt-40px"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
className="m-0 pb-3"
|
||||
isInvalid={error !== null}
|
||||
>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
|
||||
{intl.formatMessage(messages['profile.education.education'])}
|
||||
</p>
|
||||
<select
|
||||
data-hj-suppress
|
||||
className="form-control py-10px"
|
||||
id={formId}
|
||||
name={formId}
|
||||
value={levelOfEducation}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<label className="edit-section-header" htmlFor={formId}>
|
||||
{intl.formatMessage(messages['profile.education.education'])}
|
||||
</label>
|
||||
<select
|
||||
data-hj-suppress
|
||||
className="form-control"
|
||||
id={formId}
|
||||
name={formId}
|
||||
value={levelOfEducation}
|
||||
onChange={this.handleChange}
|
||||
>
|
||||
<option value=""> </option>
|
||||
{EDUCATION_LEVELS.map(level => (
|
||||
<option key={level} value={level}>
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.education.levels.${level}`,
|
||||
messages['profile.education.levels.o'],
|
||||
))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityLevelOfEducation"
|
||||
saveState={saveState}
|
||||
visibility={visibilityLevelOfEducation}
|
||||
cancelHandler={this.handleClose}
|
||||
changeHandler={this.handleChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.education.education'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilityLevelOfEducation !== null}
|
||||
<option value=""> </option>
|
||||
{EDUCATION_LEVELS.map(level => (
|
||||
<option key={level} value={level}>
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.education.levels.${level}`,
|
||||
messages['profile.education.levels.o'],
|
||||
))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityLevelOfEducation"
|
||||
saveState={saveState}
|
||||
visibility={visibilityLevelOfEducation}
|
||||
cancelHandler={handleClose}
|
||||
changeHandler={handleChange}
|
||||
/>
|
||||
<p data-hj-suppress className="h5">
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.education.levels.${levelOfEducation}`,
|
||||
messages['profile.education.levels.o'],
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.education.education'])} />
|
||||
<EmptyContent onClick={this.handleOpen}>
|
||||
<FormattedMessage
|
||||
id="profile.education.empty"
|
||||
defaultMessage="Add education"
|
||||
description="instructions when the user doesn't have their level of education set"
|
||||
/>
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.education.education'])} />
|
||||
<p data-hj-suppress className="h5">
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.education.levels.${levelOfEducation}`,
|
||||
messages['profile.education.levels.o'],
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.education.education'])}
|
||||
</p>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.education.levels.${levelOfEducation}`,
|
||||
messages['profile.education.levels.o'],
|
||||
))}
|
||||
showEditButton
|
||||
onClickEdit={handleOpen}
|
||||
showVisibility={visibilityLevelOfEducation !== null && isVisibilityEnabled}
|
||||
visibility={visibilityLevelOfEducation}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.education.education'])}
|
||||
</p>
|
||||
<EmptyContent onClick={handleOpen}>
|
||||
<FormattedMessage
|
||||
id="profile.education.empty"
|
||||
defaultMessage="Add level of education"
|
||||
description="instructions when the user doesn't have their level of education set"
|
||||
/>
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.education.education'])}
|
||||
</p>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.education.levels.${levelOfEducation}`,
|
||||
messages['profile.education.levels.o'],
|
||||
))}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Education.propTypes = {
|
||||
// It'd be nice to just set this as a defaultProps...
|
||||
// except the class that comes out on the other side of react-redux's
|
||||
// connect() method won't have it anymore. Static properties won't survive
|
||||
// through the higher order function.
|
||||
formId: PropTypes.string.isRequired,
|
||||
|
||||
// From Selector
|
||||
levelOfEducation: PropTypes.string,
|
||||
visibilityLevelOfEducation: PropTypes.oneOf(['private', 'all_users']),
|
||||
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
|
||||
saveState: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
|
||||
// Actions
|
||||
changeHandler: PropTypes.func.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
closeHandler: PropTypes.func.isRequired,
|
||||
openHandler: PropTypes.func.isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Education.defaultProps = {
|
||||
@@ -188,4 +168,4 @@ Education.defaultProps = {
|
||||
export default connect(
|
||||
editableFormSelector,
|
||||
{},
|
||||
)(injectIntl(Education));
|
||||
)(Education);
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import get from 'lodash.get';
|
||||
|
||||
// Mock Data
|
||||
import mockData from '../data/mock_data';
|
||||
|
||||
import messages from './LearningGoal.messages';
|
||||
|
||||
// Components
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Selectors
|
||||
import { editableFormSelector } from '../data/selectors';
|
||||
|
||||
const LearningGoal = (props) => {
|
||||
let { learningGoal, editMode, visibilityLearningGoal } = props;
|
||||
const { intl } = props;
|
||||
|
||||
if (!learningGoal) {
|
||||
learningGoal = mockData.learningGoal;
|
||||
}
|
||||
|
||||
if (!editMode || editMode === 'empty') { // editMode defaults to 'empty', not sure why yet
|
||||
editMode = mockData.editMode;
|
||||
}
|
||||
|
||||
if (!visibilityLearningGoal) {
|
||||
visibilityLearningGoal = mockData.visibilityLearningGoal;
|
||||
}
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-5"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])}
|
||||
showVisibility={visibilityLearningGoal !== null}
|
||||
visibility={visibilityLearningGoal}
|
||||
/>
|
||||
<p data-hj-suppress className="lead">
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.learningGoal.options.${learningGoal}`,
|
||||
messages['profile.learningGoal.options.something_else'],
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])} />
|
||||
<p data-hj-suppress className="lead">
|
||||
{intl.formatMessage(get(
|
||||
messages,
|
||||
`profile.learningGoal.options.${learningGoal}`,
|
||||
messages['profile.learningGoal.options.something_else'],
|
||||
))}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LearningGoal.propTypes = {
|
||||
// From Selector
|
||||
learningGoal: PropTypes.oneOf(['advance_career', 'start_career', 'learn_something_new', 'something_else']),
|
||||
visibilityLearningGoal: PropTypes.oneOf(['private', 'all_users']),
|
||||
editMode: PropTypes.oneOf(['editable', 'static']),
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
LearningGoal.defaultProps = {
|
||||
editMode: 'static',
|
||||
learningGoal: null,
|
||||
visibilityLearningGoal: 'private',
|
||||
};
|
||||
|
||||
export default connect(
|
||||
editableFormSelector,
|
||||
{},
|
||||
)(injectIntl(LearningGoal));
|
||||
@@ -1,31 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'profile.learningGoal.learningGoal': {
|
||||
id: 'profile.learningGoal.learningGoal',
|
||||
defaultMessage: 'Learning Goal',
|
||||
description: 'A section of a user profile that displays their current learning goal.',
|
||||
},
|
||||
'profile.learningGoal.options.start_career': {
|
||||
id: 'profile.learningGoal.options.start_career',
|
||||
defaultMessage: 'I want to start my career',
|
||||
description: 'Selected by user if their goal is to start their career.',
|
||||
},
|
||||
'profile.learningGoal.options.advance_career': {
|
||||
id: 'profile.learningGoal.options.advance_career',
|
||||
defaultMessage: 'I want to advance my career',
|
||||
description: 'Selected by user if their goal is to advance their career.',
|
||||
},
|
||||
'profile.learningGoal.options.learn_something_new': {
|
||||
id: 'profile.learningGoal.options.learn_something_new',
|
||||
defaultMessage: 'I want to learn something new',
|
||||
description: 'Selected by user if their goal is to learn something new.',
|
||||
},
|
||||
'profile.learningGoal.options.something_else': {
|
||||
id: 'profile.learningGoal.options.something_else',
|
||||
defaultMessage: 'Something else',
|
||||
description: 'Selected by user if their goal is not described by the other choices.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,122 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import renderer from 'react-test-renderer';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import messages from '../../i18n';
|
||||
|
||||
import viewOwnProfileMockStore from '../__mocks__/viewOwnProfile.mockStore';
|
||||
import savingEditedBioMockStore from '../__mocks__/savingEditedBio.mockStore';
|
||||
|
||||
import LearningGoal from './LearningGoal';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
// props to be passed down to LearningGoal component
|
||||
const requiredLearningGoalProps = {
|
||||
formId: 'learningGoal',
|
||||
learningGoal: 'advance_career',
|
||||
drafts: {},
|
||||
visibilityLearningGoal: 'private',
|
||||
editMode: 'static',
|
||||
saveState: null,
|
||||
error: null,
|
||||
openHandler: jest.fn(),
|
||||
};
|
||||
|
||||
configureI18n({
|
||||
loggingService: { logError: jest.fn() },
|
||||
config: {
|
||||
ENVIRONMENT: 'production',
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
|
||||
},
|
||||
messages,
|
||||
});
|
||||
|
||||
const LearningGoalWrapper = (props) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
authenticatedUser: { userId: null, username: null, administrator: false },
|
||||
config: getConfig(),
|
||||
}), []);
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={props.store}>
|
||||
<LearningGoal {...props} />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
LearningGoalWrapper.defaultProps = {
|
||||
store: mockStore(viewOwnProfileMockStore),
|
||||
};
|
||||
|
||||
LearningGoalWrapper.propTypes = {
|
||||
store: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
const LearningGoalWrapperWithStore = ({ store }) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
authenticatedUser: { userId: null, username: null, administrator: false },
|
||||
config: getConfig(),
|
||||
}), []);
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={mockStore(store)}>
|
||||
<LearningGoal {...requiredLearningGoalProps} formId="learningGoal" />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
LearningGoalWrapperWithStore.defaultProps = {
|
||||
store: mockStore(savingEditedBioMockStore),
|
||||
};
|
||||
|
||||
LearningGoalWrapperWithStore.propTypes = {
|
||||
store: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
describe('<LearningGoal />', () => {
|
||||
describe('renders the current learning goal', () => {
|
||||
it('renders "I want to advance my career"', () => {
|
||||
const learningGoalRenderer = renderer.create(
|
||||
<LearningGoalWrapper
|
||||
{...requiredLearningGoalProps}
|
||||
formId="learningGoal"
|
||||
/>,
|
||||
);
|
||||
|
||||
const learningGoalInstance = learningGoalRenderer.root;
|
||||
|
||||
expect(learningGoalInstance.findByProps({ className: 'lead' }).children).toEqual(['I want to advance my career']);
|
||||
});
|
||||
|
||||
it('renders "Something else"', () => {
|
||||
requiredLearningGoalProps.learningGoal = 'something_else';
|
||||
|
||||
const learningGoalRenderer = renderer.create(
|
||||
<LearningGoalWrapper
|
||||
{...requiredLearningGoalProps}
|
||||
formId="learningGoal"
|
||||
/>,
|
||||
);
|
||||
|
||||
const learningGoalInstance = learningGoalRenderer.root;
|
||||
|
||||
expect(learningGoalInstance.findByProps({ className: 'lead' }).children).toEqual(['Something else']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,147 +1,182 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { InfoOutline } from '@openedx/paragon/icons';
|
||||
import { Hyperlink, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
import messages from './Name.messages';
|
||||
|
||||
// Components
|
||||
import FormControls from './elements/FormControls';
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import EmptyContent from './elements/EmptyContent';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Selectors
|
||||
import { editableFormSelector } from '../data/selectors';
|
||||
import {
|
||||
useCloseOpenHandler,
|
||||
useHandleChange,
|
||||
useHandleSubmit,
|
||||
useIsVisibilityEnabled,
|
||||
} from '../data/hooks';
|
||||
|
||||
class Name extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const Name = ({
|
||||
formId,
|
||||
name,
|
||||
visibilityName,
|
||||
editMode,
|
||||
saveState,
|
||||
changeHandler,
|
||||
submitHandler,
|
||||
closeHandler,
|
||||
openHandler,
|
||||
accountSettingsUrl,
|
||||
}) => {
|
||||
const isVisibilityEnabled = useIsVisibilityEnabled();
|
||||
const intl = useIntl();
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
}
|
||||
const handleChange = useHandleChange(changeHandler);
|
||||
const handleSubmit = useHandleSubmit(submitHandler, formId);
|
||||
const handleOpen = useCloseOpenHandler(openHandler, formId);
|
||||
const handleClose = useCloseOpenHandler(closeHandler, formId);
|
||||
|
||||
handleChange(e) {
|
||||
const {
|
||||
name,
|
||||
value,
|
||||
} = e.target;
|
||||
this.props.changeHandler(name, value);
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.submitHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.props.closeHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.props.openHandler(this.props.formId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formId, name, visibilityName, editMode, saveState, intl,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-5"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<div className="form-group">
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
|
||||
{/*
|
||||
This isn't a mistake - the name field should not be editable. But if it were,
|
||||
you'd find the original code got deleted in the commit which added this comment.
|
||||
-djoy
|
||||
TODO: Relatedly, the plumbing for editing the name field is still in place.
|
||||
Once we're super sure we don't want it back, you could delete the name props and
|
||||
such to fully get rid of it.
|
||||
*/}
|
||||
<p data-hj-suppress className="h5">{name}</p>
|
||||
<small className="form-text text-muted" id={`${formId}-help-text`}>
|
||||
{intl.formatMessage(messages['profile.name.details'])}
|
||||
</small>
|
||||
return (
|
||||
<SwitchContent
|
||||
className="pt-40px"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<div className="row m-0 pb-2.5 align-items-center">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0">
|
||||
{intl.formatMessage(messages['profile.name.full.name'])}
|
||||
</p>
|
||||
<OverlayTrigger
|
||||
key="top"
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id="tooltip-top">
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.name.tooltip'])}
|
||||
</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<InfoOutline className="m-0 info-icon" />
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
<FormControls
|
||||
visibilityId="visibilityName"
|
||||
saveState={saveState}
|
||||
visibility={visibilityName}
|
||||
cancelHandler={this.handleClose}
|
||||
changeHandler={this.handleChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.name.full.name'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilityName !== null}
|
||||
<EditableItemHeader content={name} />
|
||||
<h4 className="font-weight-normal">
|
||||
<Hyperlink destination={accountSettingsUrl} target="_blank">
|
||||
{intl.formatMessage(messages['profile.name.redirect'])}
|
||||
</Hyperlink>
|
||||
</h4>
|
||||
</div>
|
||||
<FormControls
|
||||
visibilityId="visibilityName"
|
||||
saveState={saveState}
|
||||
visibility={visibilityName}
|
||||
cancelHandler={handleClose}
|
||||
changeHandler={handleChange}
|
||||
/>
|
||||
<p data-hj-suppress className="h5">{name}</p>
|
||||
<small className="form-text text-muted">
|
||||
{intl.formatMessage(messages['profile.name.details'])}
|
||||
</small>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
|
||||
<EmptyContent onClick={this.handleOpen}>
|
||||
{intl.formatMessage(messages['profile.name.empty'])}
|
||||
</EmptyContent>
|
||||
<small className="form-text text-muted">
|
||||
{intl.formatMessage(messages['profile.name.details'])}
|
||||
</small>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
|
||||
<p data-hj-suppress className="h5">{name}</p>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<div className="row m-0 pb-1.5 align-items-center">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0">
|
||||
{intl.formatMessage(messages['profile.name.full.name'])}
|
||||
</p>
|
||||
<OverlayTrigger
|
||||
key="top"
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id="tooltip-top">
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.name.tooltip'])}
|
||||
</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<InfoOutline className="m-0 info-icon" />
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
<EditableItemHeader
|
||||
content={name}
|
||||
showEditButton
|
||||
onClickEdit={handleOpen}
|
||||
showVisibility={visibilityName !== null && isVisibilityEnabled}
|
||||
visibility={visibilityName}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<div className="row m-0 pb-1.5 align-items-center">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0">
|
||||
{intl.formatMessage(messages['profile.name.full.name'])}
|
||||
</p>
|
||||
<OverlayTrigger
|
||||
key="top"
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id="tooltip-top">
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.name.tooltip'])}
|
||||
</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<InfoOutline className="m-0 info-icon" />
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
<EmptyContent onClick={handleOpen}>
|
||||
{intl.formatMessage(messages['profile.name.empty'])}
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<div className="row m-0 pb-1.5 align-items-center">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0">
|
||||
{intl.formatMessage(messages['profile.name.full.name'])}
|
||||
</p>
|
||||
<OverlayTrigger
|
||||
key="top"
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id="tooltip-top">
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.name.tooltip'])}
|
||||
</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<InfoOutline className="m-0 info-icon" />
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
<EditableItemHeader content={name} />
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Name.propTypes = {
|
||||
// It'd be nice to just set this as a defaultProps...
|
||||
// except the class that comes out on the other side of react-redux's
|
||||
// connect() method won't have it anymore. Static properties won't survive
|
||||
// through the higher order function.
|
||||
formId: PropTypes.string.isRequired,
|
||||
|
||||
// From Selector
|
||||
name: PropTypes.string,
|
||||
visibilityName: PropTypes.oneOf(['private', 'all_users']),
|
||||
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
|
||||
saveState: PropTypes.string,
|
||||
|
||||
// Actions
|
||||
changeHandler: PropTypes.func.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
closeHandler: PropTypes.func.isRequired,
|
||||
openHandler: PropTypes.func.isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
accountSettingsUrl: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
Name.defaultProps = {
|
||||
@@ -154,4 +189,4 @@ Name.defaultProps = {
|
||||
export default connect(
|
||||
editableFormSelector,
|
||||
{},
|
||||
)(injectIntl(Name));
|
||||
)(Name);
|
||||
|
||||
@@ -3,19 +3,24 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
'profile.name.full.name': {
|
||||
id: 'profile.name.full.name',
|
||||
defaultMessage: 'Full Name',
|
||||
defaultMessage: 'Full name',
|
||||
description: 'A section of a user profile',
|
||||
},
|
||||
'profile.name.details': {
|
||||
id: 'profile.name.details',
|
||||
defaultMessage: 'This is the name that appears in your account and on your certificates.',
|
||||
description: 'Describes the area for a user to update their name.',
|
||||
},
|
||||
'profile.name.empty': {
|
||||
id: 'profile.name.empty',
|
||||
defaultMessage: 'Add name',
|
||||
defaultMessage: 'Add full name',
|
||||
description: 'The affordance to add a name to a user’s profile.',
|
||||
},
|
||||
'profile.name.tooltip': {
|
||||
id: 'profile.name.tooltip',
|
||||
defaultMessage: 'The name that is used for ID verification and that appears on your certificates',
|
||||
description: 'Tooltip for the full name field.',
|
||||
},
|
||||
'profile.name.redirect': {
|
||||
id: 'profile.name.redirect',
|
||||
defaultMessage: 'Edit full name from the Accounts page',
|
||||
description: 'Redirect message for editing the name from the Accounts page.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,166 +1,140 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@openedx/paragon';
|
||||
|
||||
import messages from './PreferredLanguage.messages';
|
||||
|
||||
// Components
|
||||
import FormControls from './elements/FormControls';
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import EmptyContent from './elements/EmptyContent';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Selectors
|
||||
import { preferredLanguageSelector } from '../data/selectors';
|
||||
import {
|
||||
useCloseOpenHandler,
|
||||
useHandleSubmit,
|
||||
useIsVisibilityEnabled,
|
||||
} from '../data/hooks';
|
||||
|
||||
class PreferredLanguage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const PreferredLanguage = ({
|
||||
formId,
|
||||
languageProficiencies,
|
||||
visibilityLanguageProficiencies,
|
||||
editMode,
|
||||
saveState,
|
||||
error,
|
||||
sortedLanguages,
|
||||
languageMessages,
|
||||
changeHandler,
|
||||
submitHandler,
|
||||
closeHandler,
|
||||
openHandler,
|
||||
}) => {
|
||||
const isVisibilityEnabled = useIsVisibilityEnabled();
|
||||
const intl = useIntl();
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
// Restructure the data.
|
||||
// We deconstruct our value prop in render() so this
|
||||
// changes our data's shape back to match what came in
|
||||
if (name === this.props.formId) {
|
||||
if (value !== '') {
|
||||
this.props.changeHandler(name, [{ code: value }]);
|
||||
} else {
|
||||
this.props.changeHandler(name, []);
|
||||
}
|
||||
} else {
|
||||
this.props.changeHandler(name, value);
|
||||
const handleChange = ({ target: { name, value } }) => {
|
||||
let newValue = value;
|
||||
if (name === formId) {
|
||||
newValue = value ? [{ code: value }] : [];
|
||||
}
|
||||
}
|
||||
changeHandler(name, newValue);
|
||||
};
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.submitHandler(this.props.formId);
|
||||
}
|
||||
const handleSubmit = useHandleSubmit(submitHandler, formId);
|
||||
const handleOpen = useCloseOpenHandler(openHandler, formId);
|
||||
const handleClose = useCloseOpenHandler(closeHandler, formId);
|
||||
|
||||
handleClose() {
|
||||
this.props.closeHandler(this.props.formId);
|
||||
}
|
||||
const value = languageProficiencies.length ? languageProficiencies[0].code : '';
|
||||
|
||||
handleOpen() {
|
||||
this.props.openHandler(this.props.formId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
formId,
|
||||
languageProficiencies,
|
||||
visibilityLanguageProficiencies,
|
||||
editMode,
|
||||
saveState,
|
||||
error,
|
||||
intl,
|
||||
sortedLanguages,
|
||||
languageMessages,
|
||||
} = this.props;
|
||||
|
||||
const value = languageProficiencies.length ? languageProficiencies[0].code : '';
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-5"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={this.handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
isInvalid={error !== null}
|
||||
return (
|
||||
<SwitchContent
|
||||
className="pt-40px"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby={`${formId}-label`}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Form.Group
|
||||
controlId={formId}
|
||||
className="m-0 pb-3"
|
||||
isInvalid={error !== null}
|
||||
>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
|
||||
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
</p>
|
||||
<select
|
||||
data-hj-suppress
|
||||
id={formId}
|
||||
name={formId}
|
||||
className="form-control py-10px"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<label className="edit-section-header" htmlFor={formId}>
|
||||
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
</label>
|
||||
<select
|
||||
data-hj-suppress
|
||||
id={formId}
|
||||
name={formId}
|
||||
className="form-control"
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
>
|
||||
<option value=""> </option>
|
||||
{sortedLanguages.map(({ code, name }) => (
|
||||
<option key={code} value={code}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityLanguageProficiencies"
|
||||
saveState={saveState}
|
||||
visibility={visibilityLanguageProficiencies}
|
||||
cancelHandler={this.handleClose}
|
||||
changeHandler={this.handleChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilityLanguageProficiencies !== null}
|
||||
<option value=""> </option>
|
||||
{sortedLanguages.map(({ code, name }) => (
|
||||
<option key={code} value={code}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
{error !== null && (
|
||||
<Form.Control.Feedback hasIcon={false}>
|
||||
{error}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
<FormControls
|
||||
visibilityId="visibilityLanguageProficiencies"
|
||||
saveState={saveState}
|
||||
visibility={visibilityLanguageProficiencies}
|
||||
cancelHandler={handleClose}
|
||||
changeHandler={handleChange}
|
||||
/>
|
||||
<p data-hj-suppress className="h5">{languageMessages[value]}</p>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
/>
|
||||
<EmptyContent onClick={this.handleOpen}>
|
||||
{intl.formatMessage(messages['profile.preferredlanguage.empty'])}
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
/>
|
||||
<p data-hj-suppress className="h5">{languageMessages[value]}</p>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
</p>
|
||||
<EditableItemHeader
|
||||
content={languageMessages[value]}
|
||||
showEditButton
|
||||
onClickEdit={handleOpen}
|
||||
showVisibility={visibilityLanguageProficiencies !== null && isVisibilityEnabled}
|
||||
visibility={visibilityLanguageProficiencies}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
empty: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
</p>
|
||||
<EmptyContent onClick={handleOpen}>
|
||||
{intl.formatMessage(messages['profile.preferredlanguage.empty'])}
|
||||
</EmptyContent>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
|
||||
</p>
|
||||
<EditableItemHeader content={languageMessages[value]} />
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
PreferredLanguage.propTypes = {
|
||||
// It'd be nice to just set this as a defaultProps...
|
||||
// except the class that comes out on the other side of react-redux's
|
||||
// connect() method won't have it anymore. Static properties won't survive
|
||||
// through the higher order function.
|
||||
formId: PropTypes.string.isRequired,
|
||||
|
||||
// From Selector
|
||||
languageProficiencies: PropTypes.oneOfType([
|
||||
PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string })),
|
||||
// TODO: ProfilePageSelector should supply null values
|
||||
// instead of empty strings when no value exists
|
||||
PropTypes.oneOf(['']),
|
||||
]),
|
||||
visibilityLanguageProficiencies: PropTypes.oneOf(['private', 'all_users']),
|
||||
@@ -172,15 +146,10 @@ PreferredLanguage.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
languageMessages: PropTypes.objectOf(PropTypes.string).isRequired,
|
||||
|
||||
// Actions
|
||||
changeHandler: PropTypes.func.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
closeHandler: PropTypes.func.isRequired,
|
||||
openHandler: PropTypes.func.isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
PreferredLanguage.defaultProps = {
|
||||
@@ -194,4 +163,4 @@ PreferredLanguage.defaultProps = {
|
||||
export default connect(
|
||||
preferredLanguageSelector,
|
||||
{},
|
||||
)(injectIntl(PreferredLanguage));
|
||||
)(PreferredLanguage);
|
||||
|
||||
@@ -8,7 +8,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'profile.preferredlanguage.label': {
|
||||
id: 'profile.preferredlanguage.label',
|
||||
defaultMessage: 'Primary Language Spoken',
|
||||
defaultMessage: 'Primary language spoken',
|
||||
description: 'The label for a user’s primary spoken language.',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,158 +1,155 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Dropdown } from '@edx/paragon';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Dropdown,
|
||||
IconButton,
|
||||
Icon,
|
||||
Tooltip,
|
||||
OverlayTrigger,
|
||||
} from '@openedx/paragon';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { PhotoCamera } from '@openedx/paragon/icons';
|
||||
import { ReactComponent as DefaultAvatar } from '../assets/avatar.svg';
|
||||
|
||||
import messages from './ProfileAvatar.messages';
|
||||
|
||||
class ProfileAvatar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const ProfileAvatar = ({
|
||||
src,
|
||||
isDefault,
|
||||
onSave,
|
||||
onDelete,
|
||||
savePhotoState,
|
||||
isEditable,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const fileInput = useRef(null);
|
||||
const form = useRef(null);
|
||||
|
||||
this.fileInput = React.createRef();
|
||||
this.form = React.createRef();
|
||||
const onClickUpload = () => {
|
||||
fileInput.current.click();
|
||||
};
|
||||
|
||||
this.onClickUpload = this.onClickUpload.bind(this);
|
||||
this.onClickDelete = this.onClickDelete.bind(this);
|
||||
this.onChangeInput = this.onChangeInput.bind(this);
|
||||
this.onSubmit = this.onSubmit.bind(this);
|
||||
}
|
||||
const onClickDelete = () => {
|
||||
onDelete();
|
||||
};
|
||||
|
||||
onClickUpload() {
|
||||
this.fileInput.current.click();
|
||||
}
|
||||
|
||||
onClickDelete() {
|
||||
this.props.onDelete();
|
||||
}
|
||||
|
||||
onChangeInput() {
|
||||
this.onSubmit();
|
||||
}
|
||||
|
||||
onSubmit(e) {
|
||||
const onSubmit = (e) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
this.props.onSave(new FormData(this.form.current));
|
||||
this.form.current.reset();
|
||||
}
|
||||
onSave(new FormData(form.current));
|
||||
form.current.reset();
|
||||
};
|
||||
|
||||
renderPending() {
|
||||
return (
|
||||
<div
|
||||
className="position-absolute w-100 h-100 d-flex justify-content-center align-items-center rounded-circle"
|
||||
style={{ backgroundColor: 'rgba(0,0,0,.65)' }}
|
||||
>
|
||||
<div className="spinner-border text-primary" role="status" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const onChangeInput = () => {
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
renderMenuContent() {
|
||||
const { intl } = this.props;
|
||||
const renderPending = () => (
|
||||
<div
|
||||
className="position-absolute w-100 h-100 d-flex justify-content-center align-items-center rounded-circle bg-black bg-opacity-65"
|
||||
>
|
||||
<div className="spinner-border text-primary" role="status" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (this.props.isDefault) {
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-white btn-block"
|
||||
onClick={this.onClickUpload}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="profile.profileavatar.upload-button"
|
||||
defaultMessage="Upload Photo"
|
||||
description="Upload photo button"
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle>
|
||||
{intl.formatMessage(messages['profile.profileavatar.change-button'])}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item type="button" onClick={this.onClickUpload}>
|
||||
<FormattedMessage
|
||||
id="profile.profileavatar.upload-button"
|
||||
defaultMessage="Upload Photo"
|
||||
description="Upload photo button"
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item type="button" onClick={this.onClickDelete}>
|
||||
<FormattedMessage
|
||||
id="profile.profileavatar.remove.button"
|
||||
defaultMessage="Remove"
|
||||
description="Remove photo button"
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
renderMenu() {
|
||||
if (!this.props.isEditable) {
|
||||
const renderEditButton = () => {
|
||||
if (!isEditable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-avatar-menu-container">
|
||||
{this.renderMenuContent()}
|
||||
<div className="profile-avatar-button">
|
||||
<Dropdown>
|
||||
<OverlayTrigger
|
||||
key="top"
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id="tooltip-top">
|
||||
{!isDefault ? (
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.profileavatar.tooltip.edit'])}
|
||||
</p>
|
||||
) : (
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.profileavatar.tooltip.upload'])}
|
||||
</p>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Dropdown.Toggle
|
||||
invertColors
|
||||
isActive
|
||||
id="dropdown-toggle-with-iconbutton"
|
||||
as={IconButton}
|
||||
src={PhotoCamera}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
className="shadow-sm"
|
||||
/>
|
||||
</OverlayTrigger>
|
||||
<Dropdown.Menu className="min-width-179px p-0 m-0">
|
||||
<Dropdown.Item type="button" onClick={onClickUpload}>
|
||||
<FormattedMessage
|
||||
id="profile.profileavatar.upload-button"
|
||||
defaultMessage="Upload photo"
|
||||
description="Upload photo button"
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
{!isDefault && (
|
||||
<Dropdown.Item type="button" onClick={onClickDelete}>
|
||||
<FormattedMessage
|
||||
id="profile.profileavatar.remove.button"
|
||||
defaultMessage="Remove photo"
|
||||
description="Remove photo button"
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderAvatar() {
|
||||
const { intl } = this.props;
|
||||
|
||||
return this.props.isDefault ? (
|
||||
const renderAvatar = () => (
|
||||
isDefault ? (
|
||||
<DefaultAvatar className="text-muted" role="img" aria-hidden focusable="false" viewBox="0 0 24 24" />
|
||||
) : (
|
||||
<img
|
||||
data-hj-suppress
|
||||
className="w-100 h-100 d-block rounded-circle overflow-hidden"
|
||||
style={{ objectFit: 'cover' }}
|
||||
className="w-100 h-100 d-block rounded-circle overflow-hidden object-fit-cover"
|
||||
alt={intl.formatMessage(messages['profile.image.alt.attribute'])}
|
||||
src={this.props.src}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="profile-avatar-wrap position-relative">
|
||||
<div className="profile-avatar rounded-circle bg-light">
|
||||
{this.props.savePhotoState === 'pending' ? this.renderPending() : this.renderMenu() }
|
||||
{this.renderAvatar()}
|
||||
</div>
|
||||
<form
|
||||
ref={this.form}
|
||||
onSubmit={this.onSubmit}
|
||||
encType="multipart/form-data"
|
||||
>
|
||||
{/* The name of this input must be 'file' */}
|
||||
<input
|
||||
className="d-none form-control-file"
|
||||
ref={this.fileInput}
|
||||
type="file"
|
||||
name="file"
|
||||
id="photo-file"
|
||||
onChange={this.onChangeInput}
|
||||
accept=".jpg, .jpeg, .png"
|
||||
/>
|
||||
</form>
|
||||
return (
|
||||
<div className="profile-avatar-wrap position-relative">
|
||||
<div className="profile-avatar rounded-circle bg-light">
|
||||
{savePhotoState === 'pending' && renderPending()}
|
||||
{renderAvatar()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default injectIntl(ProfileAvatar);
|
||||
{renderEditButton()}
|
||||
<form
|
||||
ref={form}
|
||||
onSubmit={onSubmit}
|
||||
encType="multipart/form-data"
|
||||
>
|
||||
<input
|
||||
className="d-none form-control-file"
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
name="file"
|
||||
id="photo-file"
|
||||
onChange={onChangeInput}
|
||||
accept=".jpg, .jpeg, .png"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProfileAvatar.propTypes = {
|
||||
src: PropTypes.string,
|
||||
@@ -161,7 +158,6 @@ ProfileAvatar.propTypes = {
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
|
||||
isEditable: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
ProfileAvatar.defaultProps = {
|
||||
@@ -170,3 +166,5 @@ ProfileAvatar.defaultProps = {
|
||||
savePhotoState: null,
|
||||
isEditable: false,
|
||||
};
|
||||
|
||||
export default ProfileAvatar;
|
||||
|
||||
@@ -11,6 +11,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Change',
|
||||
description: 'Change photo button',
|
||||
},
|
||||
'profile.profileavatar.tooltip.edit': {
|
||||
id: 'profile.profileavatar.tooltip.edit',
|
||||
defaultMessage: 'Edit photo',
|
||||
description: 'Tooltip for edit photo button',
|
||||
},
|
||||
'profile.profileavatar.tooltip.upload': {
|
||||
id: 'profile.profileavatar.tooltip.upload',
|
||||
defaultMessage: 'Upload photo',
|
||||
description: 'Tooltip for upload photo button',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert } from '@edx/paragon';
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import { connect } from 'react-redux';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { faXTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './SocialLinks.messages';
|
||||
|
||||
// Components
|
||||
import FormControls from './elements/FormControls';
|
||||
import EditableItemHeader from './elements/EditableItemHeader';
|
||||
import EmptyContent from './elements/EmptyContent';
|
||||
import SwitchContent from './elements/SwitchContent';
|
||||
|
||||
// Selectors
|
||||
import { editableFormSelector } from '../data/selectors';
|
||||
import { useIsVisibilityEnabled } from '../data/hooks';
|
||||
|
||||
const platformDisplayInfo = {
|
||||
facebook: {
|
||||
icon: faFacebook,
|
||||
name: 'Facebook',
|
||||
},
|
||||
twitter: {
|
||||
icon: faTwitter,
|
||||
name: 'Twitter',
|
||||
x: {
|
||||
icon: faXTwitter,
|
||||
name: 'X (Twitter)',
|
||||
},
|
||||
linkedin: {
|
||||
icon: faLinkedin,
|
||||
@@ -33,181 +29,203 @@ const platformDisplayInfo = {
|
||||
},
|
||||
};
|
||||
|
||||
class SocialLinks extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const SocialLinks = ({
|
||||
formId,
|
||||
socialLinks,
|
||||
draftSocialLinksByPlatform,
|
||||
visibilitySocialLinks,
|
||||
editMode,
|
||||
saveState,
|
||||
error,
|
||||
changeHandler,
|
||||
submitHandler,
|
||||
closeHandler,
|
||||
openHandler,
|
||||
}) => {
|
||||
const isVisibilityEnabled = useIsVisibilityEnabled();
|
||||
const [activePlatform, setActivePlatform] = useState(null);
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
}
|
||||
|
||||
handleChange(e) {
|
||||
const { name, value } = e.target;
|
||||
|
||||
// The social links are a bit special. If we're updating them, we need to merge them
|
||||
// with any existing social link drafts, essentially sending a fresh copy of the whole
|
||||
// data structure back to the reducer. This helps the reducer stay simple and keeps
|
||||
// special cases out of it, concentrating them here, where they began.
|
||||
if (name !== 'visibilitySocialLinks') {
|
||||
this.props.changeHandler(
|
||||
'socialLinks',
|
||||
this.mergeWithDrafts({
|
||||
platform: name,
|
||||
// If it's an empty string, send it as null.
|
||||
// The empty string is just for the input. We want nulls.
|
||||
socialLink: value,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.props.changeHandler(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
this.props.submitHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
this.props.closeHandler(this.props.formId);
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
this.props.openHandler(this.props.formId);
|
||||
}
|
||||
|
||||
mergeWithDrafts(newSocialLink) {
|
||||
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
|
||||
const mergeWithDrafts = (newSocialLink) => {
|
||||
const knownPlatforms = ['x', 'facebook', 'linkedin'];
|
||||
const updated = [];
|
||||
knownPlatforms.forEach((platform) => {
|
||||
if (newSocialLink.platform === platform) {
|
||||
updated.push(newSocialLink);
|
||||
} else if (this.props.draftSocialLinksByPlatform[platform] !== undefined) {
|
||||
updated.push(this.props.draftSocialLinksByPlatform[platform]);
|
||||
} else if (draftSocialLinksByPlatform[platform] !== undefined) {
|
||||
updated.push(draftSocialLinksByPlatform[platform]);
|
||||
}
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
socialLinks, visibilitySocialLinks, editMode, saveState, error, intl,
|
||||
} = this.props;
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
if (name !== 'visibilitySocialLinks') {
|
||||
changeHandler(
|
||||
'socialLinks',
|
||||
mergeWithDrafts({
|
||||
platform: name,
|
||||
socialLink: value,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
changeHandler(name, value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="mb-5"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
empty: (
|
||||
<>
|
||||
<EditableItemHeader content={intl.formatMessage(messages['profile.sociallinks.social.links'])} />
|
||||
<ul className="list-unstyled">
|
||||
{socialLinks.map(({ platform }) => (
|
||||
<EmptyListItem
|
||||
key={platform}
|
||||
onClick={this.handleOpen}
|
||||
name={platformDisplayInfo[platform].name}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
static: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
submitHandler(formId);
|
||||
setActivePlatform(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
closeHandler(formId);
|
||||
setActivePlatform(null);
|
||||
};
|
||||
|
||||
const handleOpen = (platform) => {
|
||||
openHandler(formId);
|
||||
setActivePlatform(platform);
|
||||
};
|
||||
|
||||
const renderPlatformContent = (platform, socialLink, isEditing) => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group m-0">
|
||||
{error !== null && (
|
||||
<div id="social-error-feedback">
|
||||
<Alert variant="danger" dismissible={false} show>
|
||||
{error}
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
<div className="pb-3">
|
||||
<input
|
||||
className={classNames('form-control py-10px', { 'is-invalid': Boolean(error) })}
|
||||
type="text"
|
||||
id={`social-${platform}`}
|
||||
name={platform}
|
||||
value={socialLink || ''}
|
||||
onChange={handleChange}
|
||||
aria-describedby="social-error-feedback"
|
||||
/>
|
||||
<ul className="list-unstyled">
|
||||
{socialLinks
|
||||
.filter(({ socialLink }) => Boolean(socialLink))
|
||||
.map(({ platform, socialLink }) => (
|
||||
<StaticListItem
|
||||
key={platform}
|
||||
name={platformDisplayInfo[platform].name}
|
||||
url={socialLink}
|
||||
platform={platform}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
editable: (
|
||||
<>
|
||||
<EditableItemHeader
|
||||
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
|
||||
showEditButton
|
||||
onClickEdit={this.handleOpen}
|
||||
showVisibility={visibilitySocialLinks !== null}
|
||||
visibility={visibilitySocialLinks}
|
||||
/>
|
||||
<ul className="list-unstyled">
|
||||
{socialLinks.map(({ platform, socialLink }) => (
|
||||
<EditableListItem
|
||||
key={platform}
|
||||
platform={platform}
|
||||
name={platformDisplayInfo[platform].name}
|
||||
url={socialLink}
|
||||
onClickEmptyContent={this.handleOpen}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
),
|
||||
editing: (
|
||||
<div role="dialog" aria-labelledby="social-links-label">
|
||||
<form aria-labelledby="editing-form" onSubmit={this.handleSubmit}>
|
||||
<EditableItemHeader
|
||||
headingId="social-links-label"
|
||||
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
|
||||
/>
|
||||
{/* TODO: Replace this alert with per-field errors. Needs API update. */}
|
||||
<div id="social-error-feedback">
|
||||
{error !== null
|
||||
? (
|
||||
<Alert variant="danger" dismissible={false} show>
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
</div>
|
||||
<ul className="list-unstyled">
|
||||
{socialLinks.map(({ platform, socialLink }) => (
|
||||
<EditingListItem
|
||||
key={platform}
|
||||
name={platformDisplayInfo[platform].name}
|
||||
platform={platform}
|
||||
value={socialLink}
|
||||
/* TODO: Per-field errors: error={error !== null ? error[platform] : null} */
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<FormControls
|
||||
visibilityId="visibilitySocialLinks"
|
||||
saveState={saveState}
|
||||
visibility={visibilitySocialLinks}
|
||||
cancelHandler={this.handleClose}
|
||||
changeHandler={this.handleChange}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<FormControls
|
||||
visibilityId="visibilitySocialLinks"
|
||||
saveState={saveState}
|
||||
visibility={visibilitySocialLinks}
|
||||
cancelHandler={handleClose}
|
||||
changeHandler={handleChange}
|
||||
submitHandler={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
if (socialLink) {
|
||||
return (
|
||||
<div className="w-100 overflowWrap-breakWord">
|
||||
<EditableItemHeader
|
||||
content={socialLink}
|
||||
showEditButton
|
||||
onClickEdit={() => handleOpen(platform)}
|
||||
showVisibility={visibilitySocialLinks !== null && isVisibilityEnabled}
|
||||
visibility={visibilitySocialLinks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyContent onClick={() => handleOpen(platform)}>
|
||||
Add {platformDisplayInfo[platform].name}
|
||||
</EmptyContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SwitchContent
|
||||
className="p-0"
|
||||
expression={editMode}
|
||||
cases={{
|
||||
empty: (
|
||||
<div>
|
||||
<div>
|
||||
{socialLinks.map(({ platform }) => (
|
||||
<div key={platform} className="pt-40px">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{platformDisplayInfo[platform].name}
|
||||
</p>
|
||||
<EmptyContent onClick={() => handleOpen(platform)}>
|
||||
<FormattedMessage
|
||||
id="profile.sociallinks.add"
|
||||
defaultMessage="Add {network} profile"
|
||||
values={{
|
||||
network: platformDisplayInfo[platform].name,
|
||||
}}
|
||||
description="{network} is the name of a social network such as Facebook or X"
|
||||
/>
|
||||
</EmptyContent>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
static: (
|
||||
<div>
|
||||
<div>
|
||||
{socialLinks
|
||||
.filter(({ socialLink }) => Boolean(socialLink))
|
||||
.map(({ platform, socialLink }) => (
|
||||
<div key={platform} className="pt-40px">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{platformDisplayInfo[platform].name}
|
||||
</p>
|
||||
<EditableItemHeader
|
||||
content={socialLink}
|
||||
contentPrefix={`${platformDisplayInfo[platform].name}: `}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
editable: (
|
||||
<div>
|
||||
<div>
|
||||
{socialLinks.map(({ platform, socialLink }) => (
|
||||
<div key={platform} className="pt-40px">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-1.5">
|
||||
{platformDisplayInfo[platform].name}
|
||||
</p>
|
||||
{renderPlatformContent(platform, socialLink, activePlatform === platform)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
editing: (
|
||||
<div>
|
||||
<div>
|
||||
{socialLinks.map(({ platform, socialLink }) => (
|
||||
<div key={platform} className="pt-40px">
|
||||
<p data-hj-suppress className="h5 font-weight-bold m-0 pb-2.5">
|
||||
{platformDisplayInfo[platform].name}
|
||||
</p>
|
||||
{renderPlatformContent(platform, socialLink, activePlatform === platform)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
SocialLinks.propTypes = {
|
||||
// It'd be nice to just set this as a defaultProps...
|
||||
// except the class that comes out on the other side of react-redux's
|
||||
// connect() method won't have it anymore. Static properties won't survive
|
||||
// through the higher order function.
|
||||
formId: PropTypes.string.isRequired,
|
||||
|
||||
// From Selector
|
||||
socialLinks: PropTypes.arrayOf(PropTypes.shape({
|
||||
platform: PropTypes.string,
|
||||
socialLink: PropTypes.string,
|
||||
@@ -220,15 +238,10 @@ SocialLinks.propTypes = {
|
||||
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
|
||||
saveState: PropTypes.string,
|
||||
error: PropTypes.string,
|
||||
|
||||
// Actions
|
||||
changeHandler: PropTypes.func.isRequired,
|
||||
submitHandler: PropTypes.func.isRequired,
|
||||
closeHandler: PropTypes.func.isRequired,
|
||||
openHandler: PropTypes.func.isRequired,
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
SocialLinks.defaultProps = {
|
||||
@@ -242,106 +255,4 @@ SocialLinks.defaultProps = {
|
||||
export default connect(
|
||||
editableFormSelector,
|
||||
{},
|
||||
)(injectIntl(SocialLinks));
|
||||
|
||||
const SocialLink = ({ url, name, platform }) => (
|
||||
<a href={url} className="font-weight-bold">
|
||||
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
|
||||
SocialLink.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
platform: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const EditableListItem = ({
|
||||
url, platform, onClickEmptyContent, name,
|
||||
}) => {
|
||||
const linkDisplay = url ? (
|
||||
<SocialLink name={name} url={url} platform={platform} />
|
||||
) : (
|
||||
<EmptyContent onClick={onClickEmptyContent}>Add {name}</EmptyContent>
|
||||
);
|
||||
|
||||
return <li className="form-group">{linkDisplay}</li>;
|
||||
};
|
||||
|
||||
EditableListItem.propTypes = {
|
||||
url: PropTypes.string,
|
||||
platform: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
onClickEmptyContent: PropTypes.func,
|
||||
};
|
||||
EditableListItem.defaultProps = {
|
||||
url: null,
|
||||
onClickEmptyContent: null,
|
||||
};
|
||||
|
||||
const EditingListItem = ({
|
||||
platform, name, value, onChange, error,
|
||||
}) => (
|
||||
<li className="form-group">
|
||||
<label htmlFor={`social-${platform}`}>{name}</label>
|
||||
<input
|
||||
className={classNames('form-control', { 'is-invalid': Boolean(error) })}
|
||||
type="text"
|
||||
id={`social-${platform}`}
|
||||
name={platform}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
aria-describedby="social-error-feedback"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
||||
EditingListItem.propTypes = {
|
||||
platform: PropTypes.string.isRequired,
|
||||
value: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
error: PropTypes.string,
|
||||
};
|
||||
|
||||
EditingListItem.defaultProps = {
|
||||
value: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const EmptyListItem = ({ onClick, name }) => (
|
||||
<li className="mb-4">
|
||||
<EmptyContent onClick={onClick}>
|
||||
<FormattedMessage
|
||||
id="profile.sociallinks.add"
|
||||
defaultMessage="Add {network}"
|
||||
values={{
|
||||
network: name,
|
||||
}}
|
||||
description="{network} is the name of a social network such as Facebook or Twitter"
|
||||
/>
|
||||
</EmptyContent>
|
||||
</li>
|
||||
);
|
||||
|
||||
EmptyListItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
const StaticListItem = ({ name, url, platform }) => (
|
||||
<li className="mb-2">
|
||||
<SocialLink name={name} url={url} platform={platform} />
|
||||
</li>
|
||||
);
|
||||
|
||||
StaticListItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
url: PropTypes.string,
|
||||
platform: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
StaticListItem.defaultProps = {
|
||||
url: null,
|
||||
};
|
||||
)(SocialLinks);
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { mount } from 'enzyme';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import renderer from 'react-test-renderer';
|
||||
import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import SocialLinks from './SocialLinks';
|
||||
import * as savingEditedBio from '../__mocks__/savingEditedBio.mockStore';
|
||||
import messages from '../../i18n';
|
||||
|
||||
const mockStore = configureMockStore([thunk]);
|
||||
|
||||
const defaultProps = {
|
||||
formId: 'socialLinks',
|
||||
socialLinks: [
|
||||
{
|
||||
platform: 'facebook',
|
||||
socialLink: 'https://www.facebook.com/aloha',
|
||||
},
|
||||
{
|
||||
platform: 'twitter',
|
||||
socialLink: 'https://www.twitter.com/ALOHA',
|
||||
},
|
||||
],
|
||||
drafts: {},
|
||||
visibilitySocialLinks: 'private',
|
||||
editMode: 'static',
|
||||
saveState: null,
|
||||
error: null,
|
||||
changeHandler: jest.fn(),
|
||||
submitHandler: jest.fn(),
|
||||
closeHandler: jest.fn(),
|
||||
openHandler: jest.fn(),
|
||||
};
|
||||
|
||||
configureI18n({
|
||||
loggingService: { logError: jest.fn() },
|
||||
config: {
|
||||
ENVIRONMENT: 'production',
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
|
||||
},
|
||||
messages,
|
||||
});
|
||||
|
||||
const SocialLinksWrapper = (props) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
authenticatedUser: { userId: null, username: null, administrator: false },
|
||||
config: getConfig(),
|
||||
}), []);
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={props.store}>
|
||||
<SocialLinks {...props} />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
SocialLinksWrapper.defaultProps = {
|
||||
store: mockStore(savingEditedBio),
|
||||
};
|
||||
|
||||
SocialLinksWrapper.propTypes = {
|
||||
store: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
const SocialLinksWrapperWithStore = ({ store }) => {
|
||||
const contextValue = useMemo(() => ({
|
||||
authenticatedUser: { userId: null, username: null, administrator: false },
|
||||
config: getConfig(),
|
||||
}), []);
|
||||
return (
|
||||
<AppContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={mockStore(store)}>
|
||||
<SocialLinks {...defaultProps} formId="bio" />
|
||||
</Provider>
|
||||
</IntlProvider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
SocialLinksWrapperWithStore.defaultProps = {
|
||||
store: mockStore(savingEditedBio),
|
||||
};
|
||||
|
||||
SocialLinksWrapperWithStore.propTypes = {
|
||||
store: PropTypes.shape({}),
|
||||
};
|
||||
|
||||
describe('<SocialLinks />', () => {
|
||||
['certificates', 'bio', 'goals', 'socialLinks'].forEach(editMode => (
|
||||
it(`calls social links with edit mode ${editMode}`, () => {
|
||||
const component = <SocialLinksWrapper {...defaultProps} formId={editMode} />;
|
||||
const tree = renderer.create(component).toJSON();
|
||||
expect(tree).toMatchSnapshot();
|
||||
})
|
||||
));
|
||||
|
||||
it('calls social links with editing', () => {
|
||||
const changeHandler = jest.fn();
|
||||
const submitHandler = jest.fn();
|
||||
const closeHandler = jest.fn();
|
||||
const component = (
|
||||
<SocialLinksWrapper
|
||||
{...defaultProps}
|
||||
formId="bio"
|
||||
changeHandler={changeHandler}
|
||||
submitHandler={submitHandler}
|
||||
closeHandler={closeHandler}
|
||||
/>
|
||||
);
|
||||
const wrapper = mount(component);
|
||||
const socialLink = wrapper.find(SocialLinks);
|
||||
const { platform } = defaultProps.socialLinks[0];
|
||||
const inputField = socialLink.find(`#social-${platform}`);
|
||||
inputField.simulate('change', { target: { value: 'test', name: platform } });
|
||||
expect(changeHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(socialLink.find('#visibilitySocialLinks select').props().value).toBe('private');
|
||||
const event = { target: { value: 'all_users', name: 'visibilitySocialLinks' } };
|
||||
socialLink.find('#visibilitySocialLinks select').simulate('change', event);
|
||||
expect(changeHandler).toHaveBeenCalledTimes(2);
|
||||
|
||||
socialLink.find('[aria-labelledby="editing-form"]').simulate('submit');
|
||||
expect(submitHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
socialLink.find('[aria-labelledby="editing-form"]').find('Button .btn-link').simulate('click');
|
||||
expect(closeHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls social links with static', () => {
|
||||
const openHandler = jest.fn();
|
||||
const component = (
|
||||
<SocialLinksWrapper
|
||||
{...defaultProps}
|
||||
formId="goals"
|
||||
openHandler={openHandler}
|
||||
/>
|
||||
);
|
||||
const wrapper = mount(component);
|
||||
const socialLink = wrapper.find(SocialLinks);
|
||||
|
||||
socialLink.find('EmptyContent button').first().simulate('click');
|
||||
expect(openHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls social links with error', () => {
|
||||
const newStore = JSON.parse(JSON.stringify(savingEditedBio));
|
||||
newStore.profilePage.errors.bio = { userMessage: 'error' };
|
||||
|
||||
const component = <SocialLinksWrapperWithStore store={newStore} />;
|
||||
const wrapper = mount(component);
|
||||
const socialLink = wrapper.find(SocialLinks);
|
||||
|
||||
expect(socialLink.find('.alert-danger').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,586 +0,0 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<SocialLinks /> calls social links with edit mode bio 1`] = `
|
||||
<div
|
||||
className="pgn-transition-replace-group position-relative mb-5"
|
||||
style={
|
||||
Object {
|
||||
"height": null,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"padding": ".1px 0",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
aria-labelledby="social-links-label"
|
||||
role="dialog"
|
||||
>
|
||||
<form
|
||||
aria-labelledby="editing-form"
|
||||
onSubmit={[Function]}
|
||||
>
|
||||
<div
|
||||
className="editable-item-header mb-2"
|
||||
>
|
||||
<h2
|
||||
className="edit-section-header"
|
||||
id="social-links-label"
|
||||
>
|
||||
Social Links
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
id="social-error-feedback"
|
||||
/>
|
||||
<ul
|
||||
className="list-unstyled"
|
||||
>
|
||||
<li
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
htmlFor="social-facebook"
|
||||
>
|
||||
Facebook
|
||||
</label>
|
||||
<input
|
||||
aria-describedby="social-error-feedback"
|
||||
className="form-control"
|
||||
id="social-facebook"
|
||||
name="facebook"
|
||||
onChange={[Function]}
|
||||
type="text"
|
||||
value="https://www.facebook.com/aloha"
|
||||
/>
|
||||
</li>
|
||||
<li
|
||||
className="form-group"
|
||||
>
|
||||
<label
|
||||
htmlFor="social-twitter"
|
||||
>
|
||||
Twitter
|
||||
</label>
|
||||
<input
|
||||
aria-describedby="social-error-feedback"
|
||||
className="form-control"
|
||||
id="social-twitter"
|
||||
name="twitter"
|
||||
onChange={[Function]}
|
||||
type="text"
|
||||
value="https://www.twitter.com/ALOHA"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
className="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center"
|
||||
>
|
||||
<div
|
||||
className="form-group d-flex flex-wrap"
|
||||
>
|
||||
<label
|
||||
className="col-form-label"
|
||||
htmlFor="visibilitySocialLinks"
|
||||
>
|
||||
Who can see this:
|
||||
</label>
|
||||
<span
|
||||
className="d-flex align-items-center"
|
||||
>
|
||||
<span
|
||||
className="d-inline-block ml-1 mr-2"
|
||||
style={
|
||||
Object {
|
||||
"width": "1.5rem",
|
||||
}
|
||||
}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-eye-slash fa-w-20 "
|
||||
data-icon="eye-slash"
|
||||
data-prefix="far"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 640 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M634 471L36 3.51A16 16 0 0 0 13.51 6l-10 12.49A16 16 0 0 0 6 41l598 467.49a16 16 0 0 0 22.49-2.49l10-12.49A16 16 0 0 0 634 471zM296.79 146.47l134.79 105.38C429.36 191.91 380.48 144 320 144a112.26 112.26 0 0 0-23.21 2.47zm46.42 219.07L208.42 260.16C210.65 320.09 259.53 368 320 368a113 113 0 0 0 23.21-2.46zM320 112c98.65 0 189.09 55 237.93 144a285.53 285.53 0 0 1-44 60.2l37.74 29.5a333.7 333.7 0 0 0 52.9-75.11 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64c-36.7 0-71.71 7-104.63 18.81l46.41 36.29c18.94-4.3 38.34-7.1 58.22-7.1zm0 288c-98.65 0-189.08-55-237.93-144a285.47 285.47 0 0 1 44.05-60.19l-37.74-29.5a333.6 333.6 0 0 0-52.89 75.1 32.35 32.35 0 0 0 0 29.19C89.72 376.41 197.08 448 320 448c36.7 0 71.71-7.05 104.63-18.81l-46.41-36.28C359.28 397.2 339.89 400 320 400z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<select
|
||||
className="d-inline-block form-control"
|
||||
id="visibilitySocialLinks"
|
||||
name="visibilitySocialLinks"
|
||||
onChange={[Function]}
|
||||
type="select"
|
||||
value="private"
|
||||
>
|
||||
<option
|
||||
value="private"
|
||||
>
|
||||
Just me
|
||||
</option>
|
||||
<option
|
||||
value="all_users"
|
||||
>
|
||||
Everyone on localhost
|
||||
</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="form-group flex-shrink-0 flex-grow-1"
|
||||
>
|
||||
<button
|
||||
aria-disabled={false}
|
||||
aria-live="assertive"
|
||||
className="pgn__stateful-btn pgn__stateful-btn-state-pending btn btn-primary"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="submit"
|
||||
>
|
||||
<span
|
||||
className="d-flex align-items-center justify-content-center"
|
||||
>
|
||||
<span
|
||||
className="pgn__stateful-btn-icon"
|
||||
>
|
||||
<span
|
||||
className="pgn__icon icon-spin"
|
||||
>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
fill="none"
|
||||
focusable={false}
|
||||
height={24}
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
width={24}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Saving
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-link"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<SocialLinks /> calls social links with edit mode certificates 1`] = `
|
||||
<div
|
||||
className="pgn-transition-replace-group position-relative mb-5"
|
||||
style={
|
||||
Object {
|
||||
"height": null,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"padding": ".1px 0",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="editable-item-header mb-2"
|
||||
>
|
||||
<h2
|
||||
className="edit-section-header"
|
||||
id={null}
|
||||
>
|
||||
Social Links
|
||||
<button
|
||||
className="float-right px-0 btn btn-link btn-sm"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"marginTop": "-.35rem",
|
||||
}
|
||||
}
|
||||
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>
|
||||
</h2>
|
||||
<p
|
||||
className="mb-0"
|
||||
>
|
||||
<span
|
||||
className="ml-auto small text-muted"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-eye-slash fa-w-20 "
|
||||
data-icon="eye-slash"
|
||||
data-prefix="far"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 640 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M634 471L36 3.51A16 16 0 0 0 13.51 6l-10 12.49A16 16 0 0 0 6 41l598 467.49a16 16 0 0 0 22.49-2.49l10-12.49A16 16 0 0 0 634 471zM296.79 146.47l134.79 105.38C429.36 191.91 380.48 144 320 144a112.26 112.26 0 0 0-23.21 2.47zm46.42 219.07L208.42 260.16C210.65 320.09 259.53 368 320 368a113 113 0 0 0 23.21-2.46zM320 112c98.65 0 189.09 55 237.93 144a285.53 285.53 0 0 1-44 60.2l37.74 29.5a333.7 333.7 0 0 0 52.9-75.11 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64c-36.7 0-71.71 7-104.63 18.81l46.41 36.29c18.94-4.3 38.34-7.1 58.22-7.1zm0 288c-98.65 0-189.08-55-237.93-144a285.47 285.47 0 0 1 44.05-60.19l-37.74-29.5a333.6 333.6 0 0 0-52.89 75.1 32.35 32.35 0 0 0 0 29.19C89.72 376.41 197.08 448 320 448c36.7 0 71.71-7.05 104.63-18.81l-46.41-36.28C359.28 397.2 339.89 400 320 400z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Just me
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<ul
|
||||
className="list-unstyled"
|
||||
>
|
||||
<li
|
||||
className="form-group"
|
||||
>
|
||||
<a
|
||||
className="font-weight-bold"
|
||||
href="https://www.facebook.com/aloha"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-facebook fa-w-16 mr-2"
|
||||
data-icon="facebook"
|
||||
data-prefix="fab"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
Facebook
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className="form-group"
|
||||
>
|
||||
<a
|
||||
className="font-weight-bold"
|
||||
href="https://www.twitter.com/ALOHA"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-twitter fa-w-16 mr-2"
|
||||
data-icon="twitter"
|
||||
data-prefix="fab"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
Twitter
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<SocialLinks /> calls social links with edit mode goals 1`] = `
|
||||
<div
|
||||
className="pgn-transition-replace-group position-relative mb-5"
|
||||
style={
|
||||
Object {
|
||||
"height": null,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"padding": ".1px 0",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="editable-item-header mb-2"
|
||||
>
|
||||
<h2
|
||||
className="edit-section-header"
|
||||
id={null}
|
||||
>
|
||||
Social Links
|
||||
</h2>
|
||||
</div>
|
||||
<ul
|
||||
className="list-unstyled"
|
||||
>
|
||||
<li
|
||||
className="mb-4"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
className="pl-0 text-left btn btn-link"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-plus fa-w-14 fa-xs mr-2"
|
||||
data-icon="plus"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 448 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
Add Facebook
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li
|
||||
className="mb-4"
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
className="pl-0 text-left btn btn-link"
|
||||
onClick={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-plus fa-w-14 fa-xs mr-2"
|
||||
data-icon="plus"
|
||||
data-prefix="fas"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 448 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
Add Twitter
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<SocialLinks /> calls social links with edit mode socialLinks 1`] = `
|
||||
<div
|
||||
className="pgn-transition-replace-group position-relative mb-5"
|
||||
style={
|
||||
Object {
|
||||
"height": null,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={
|
||||
Object {
|
||||
"padding": ".1px 0",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="editable-item-header mb-2"
|
||||
>
|
||||
<h2
|
||||
className="edit-section-header"
|
||||
id={null}
|
||||
>
|
||||
Social Links
|
||||
<button
|
||||
className="float-right px-0 btn btn-link btn-sm"
|
||||
disabled={false}
|
||||
onClick={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"marginTop": "-.35rem",
|
||||
}
|
||||
}
|
||||
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>
|
||||
</h2>
|
||||
<p
|
||||
className="mb-0"
|
||||
>
|
||||
<span
|
||||
className="ml-auto small text-muted"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-eye-slash fa-w-20 "
|
||||
data-icon="eye-slash"
|
||||
data-prefix="far"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 640 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M634 471L36 3.51A16 16 0 0 0 13.51 6l-10 12.49A16 16 0 0 0 6 41l598 467.49a16 16 0 0 0 22.49-2.49l10-12.49A16 16 0 0 0 634 471zM296.79 146.47l134.79 105.38C429.36 191.91 380.48 144 320 144a112.26 112.26 0 0 0-23.21 2.47zm46.42 219.07L208.42 260.16C210.65 320.09 259.53 368 320 368a113 113 0 0 0 23.21-2.46zM320 112c98.65 0 189.09 55 237.93 144a285.53 285.53 0 0 1-44 60.2l37.74 29.5a333.7 333.7 0 0 0 52.9-75.11 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64c-36.7 0-71.71 7-104.63 18.81l46.41 36.29c18.94-4.3 38.34-7.1 58.22-7.1zm0 288c-98.65 0-189.08-55-237.93-144a285.47 285.47 0 0 1 44.05-60.19l-37.74-29.5a333.6 333.6 0 0 0-52.89 75.1 32.35 32.35 0 0 0 0 29.19C89.72 376.41 197.08 448 320 448c36.7 0 71.71-7.05 104.63-18.81l-46.41-36.28C359.28 397.2 339.89 400 320 400z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Just me
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<ul
|
||||
className="list-unstyled"
|
||||
>
|
||||
<li
|
||||
className="form-group"
|
||||
>
|
||||
<a
|
||||
className="font-weight-bold"
|
||||
href="https://www.facebook.com/aloha"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-facebook fa-w-16 mr-2"
|
||||
data-icon="facebook"
|
||||
data-prefix="fab"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
Facebook
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
className="form-group"
|
||||
>
|
||||
<a
|
||||
className="font-weight-bold"
|
||||
href="https://www.twitter.com/ALOHA"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="svg-inline--fa fa-twitter fa-w-16 mr-2"
|
||||
data-icon="twitter"
|
||||
data-prefix="fab"
|
||||
focusable="false"
|
||||
role="img"
|
||||
style={Object {}}
|
||||
viewBox="0 0 512 512"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
|
||||
fill="currentColor"
|
||||
style={Object {}}
|
||||
/>
|
||||
</svg>
|
||||
Twitter
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1,39 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { EditOutline } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
import messages from './EditButton.messages';
|
||||
|
||||
const EditButton = ({
|
||||
onClick, className, style, intl,
|
||||
}) => (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
|
||||
{intl.formatMessage(messages['profile.editbutton.edit'])}
|
||||
</Button>
|
||||
);
|
||||
const EditButton = ({ onClick, className = null, style = null }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<OverlayTrigger
|
||||
key="top"
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id="tooltip-top">
|
||||
<p className="h5 font-weight-normal m-0 p-0">
|
||||
{intl.formatMessage(messages['profile.editbutton.edit'])}
|
||||
</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={className}
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<EditOutline className="text-gray-700" />
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export default injectIntl(EditButton);
|
||||
export default EditButton;
|
||||
|
||||
EditButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object, // eslint-disable-line
|
||||
|
||||
// i18n
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
EditButton.defaultProps = {
|
||||
className: null,
|
||||
style: null,
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user