Compare commits

...

102 Commits

Author SHA1 Message Date
Jason Wesson
2904168ed4 update: profile POC from master (#926)
* Update dependencies and visibility in Profile page

---------

Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-11-27 09:23:50 -08:00
Maxwell Frank
ad80493869 Merge pull request #920 from openedx/mfrank/remove-scrollbars
fix: remove scrollbars
2023-11-22 13:00:35 -05:00
Maxwell Frank
a8b803d344 fix: linting errors 2023-11-22 16:17:06 +00:00
Maxwell Frank
0340b94add fix: remove scrollbars 2023-11-22 16:01:47 +00:00
Jason Wesson
b665d3897b Jwesson/fix target link (#914)
* fix: add _parent to social media links

---------

Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-11-14 08:38:07 -08:00
Jason Wesson
542fa3d5ce fix: "view public profile" link sends parent frame to URL (#913)
Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-11-14 07:08:09 -08:00
Jason Wesson
d5949f55c2 Merge master into PluggablePOCFeature (#908)
---------

Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-11-07 20:03:44 -08:00
Jason Wesson
cd28310937 fix: hyperlink in plugin page should redirect to user profile (#897)
* add user's full name to plugn page

Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-10-24 13:04:55 -07:00
Jason Wesson
b75347ad06 Merge master into PluginPOCFeature (#896) 2023-10-24 09:51:12 -07:00
Chris Deery
bd931338d8 Merge pull request #885 from openedx/PluginPOC/addTag
feat: add little tag to identify POC
2023-10-19 12:08:44 -04:00
Chris Deery
f03d5afa0d fix: update snapshot 2023-10-19 15:58:39 +00:00
Chris Deery
910e17f75d feat: add little tag to identify POC 2023-10-19 15:34:04 +00:00
Jason Wesson
2fa5cadf22 fix: rebase pluginPOCFeature with master branch (#884)
* fix(deps): update dependency @edx/paragon to v20.46.3

* chore(deps): update commitlint monorepo to v17.8.0

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

* chore: bump frontend-platform (#869)

* fix: Add ID attribute to the main content (#845)

* chore: update browserslist DB (#871)

* fix(deps): update dependency @edx/frontend-platform to v5.6.1 (#875)

* build(deps): bump @babel/traverse from 7.22.5 to 7.23.2 (#879)

* bumps [@babel/traverse](https://github.com/babel/babel/tree/HEAD/packages/babel-traverse) from 7.22.5 to 7.23.2.

* fix(deps): update dependency core-js to v3.33.0 (#876)
2023-10-18 11:58:21 -07:00
Jason Wesson
bd8221997e Move changes from ProfilePluginPOC to aperture/PluginPOCFeature (#883)
* build: create profile plugin page
* build: add plugins folder to Profile
* build: wrap Profile Plugin Page with Plugin

Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-10-18 11:43:43 -07:00
Deborah Kaplan
43f485d841 Merge pull request #850 from openedx/update-browserslist-db
Update browserslist DB
2023-10-10 11:06:24 -04:00
renovate[bot]
b892ba763e fix(deps): update dependency @edx/frontend-component-header to v4.7.1 2023-10-09 16:53:45 +00:00
renovate[bot]
896905b457 fix(deps): update dependency @edx/frontend-component-footer to v12.3.0 2023-10-09 10:34:37 +00:00
jsnwesson
095b91c8cb chore: update browserslist DB 2023-10-09 00:09:13 +00:00
renovate[bot]
a6e63a8686 chore(deps): update dependency glob to v10.3.10 2023-10-02 17:03:40 +00:00
renovate[bot]
67c8d79aa2 fix(deps): update dependency @edx/frontend-component-header to v4.6.1 2023-10-02 13:24:41 +00:00
renovate[bot]
f76185d57d chore(deps): update dependency @edx/frontend-build to v12.9.17 2023-10-02 09:40:44 +00:00
renovate[bot]
f0678ca94c chore(deps): update dependency @commitlint/cli to v17.7.2 2023-10-02 06:30:56 +00:00
Mashal Malik
e73b646263 refactor: add openedx in renovate automate configuration (#849) 2023-09-28 12:21:18 -04:00
renovate[bot]
ddb8494471 fix(deps): update dependency @edx/frontend-platform to v5.4.0 2023-09-25 15:09:40 +00:00
edX requirements bot
a576bdf98b chore: update browserslist DB (#846)
Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-09-25 09:01:20 -04:00
renovate[bot]
21dcadba5b chore(deps): update dependency glob to v10.3.7 2023-09-25 09:21:59 +00:00
Deborah Kaplan
e770101e4e Merge pull request #842 from openedx/update-browserslist-db
Update browserslist DB
2023-09-18 12:49:47 -04:00
renovate[bot]
8ca5ea5809 fix(deps): update react-router monorepo to v6.16.0 2023-09-18 10:09:02 +00:00
renovate[bot]
d687ea30cb fix(deps): update dependency regenerator-runtime to v0.14.0 2023-09-18 07:58:45 +00:00
jsnwesson
ecda751786 chore: update browserslist DB 2023-09-18 00:08:53 +00:00
edX requirements bot
e58b174c9e chore: update browserslist DB (#839)
Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-09-15 14:37:58 -07:00
edx-transifex-bot
6c82805c7a chore(i18n): update translations (#838)
Co-authored-by: Jenkins <sre+jenkins@edx.org>
2023-09-15 14:37:47 -07:00
renovate[bot]
d1d98794ab fix(deps): update dependency @edx/frontend-platform to v5.3.2 2023-09-11 16:58:08 +00:00
renovate[bot]
3c7baaa91b fix(deps): update dependency core-js to v3.32.2 2023-09-11 09:39:16 +00:00
Syed Ali Abbas Zaidi
fe800f2ee9 feat: upgrade react router to v6 (#716)
* feat: upgrade react router to v6
* refactor: add hoc to use params
* fix: lint issue
2023-09-05 14:55:23 -04:00
Mashal Malik
e1d4e9b474 refactor: update lock file version (#834) 2023-09-05 14:49:02 -04:00
edX requirements bot
cf7568bcfb chore: update browserslist DB (#829)
Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-09-05 14:46:18 -04:00
renovate[bot]
a0fd863bc4 fix(deps): update dependency @edx/frontend-component-footer to v12.2.1 2023-09-04 09:53:06 +00:00
renovate[bot]
b63341fe99 chore(deps): update dependency glob to v10.3.4 2023-09-04 08:36:20 +00:00
renovate[bot]
1887167d0e fix(deps): update dependency core-js to v3.32.1 2023-08-21 13:43:20 +00:00
renovate[bot]
57de2b4156 chore(deps): update dependency @edx/frontend-build to v12.9.10 2023-08-21 10:09:12 +00:00
renovate[bot]
aaf6935577 fix(deps): update dependency @edx/frontend-component-header to v4.5.2 2023-08-14 16:30:15 +00:00
renovate[bot]
03b7859b20 chore(deps): update commitlint monorepo 2023-08-14 09:24:21 +00:00
edX requirements bot
2800e89f02 chore: update browserslist DB (#825)
Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-08-10 07:40:49 -04:00
Emad Rad
0cc03e5fec chore: translations updated (#828) 2023-08-07 14:02:54 -07:00
renovate[bot]
44f1a5f0cd fix(deps): update dependency @edx/frontend-platform to v4.6.1 2023-08-07 09:09:08 +00:00
renovate[bot]
2694f6f754 chore(deps): update dependency @edx/frontend-build to v12.9.4 2023-08-07 08:28:02 +00:00
renovate[bot]
18afe62590 fix(deps): update dependency core-js to v3.32.0 2023-07-31 10:26:36 +00:00
renovate[bot]
0b6d3dc9ac chore(deps): update dependency @edx/frontend-build to v12.9.3 2023-07-31 06:56:55 +00:00
edX requirements bot
bd5984f27b chore: update browserslist DB (#813)
Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-07-25 14:48:37 -04:00
Omar Al-Ithawi
f899727f8d feat: include paragon in atlas pull (#811)
This pull request is part of the [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) which is sparked by the [Translation Infrastructure update OEP-58](https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification).
2023-07-25 11:22:15 -04:00
renovate[bot]
803a2d5572 fix(deps): update dependency core-js to v3.31.1 2023-07-24 13:49:10 +00:00
renovate[bot]
93b0b1b9da fix(deps): update dependency @edx/frontend-component-header to v4.4.4 2023-07-24 10:27:39 +00:00
renovate[bot]
22b01f347d chore(deps): update commitlint monorepo to v17.6.7 2023-07-24 07:53:18 +00:00
Mashal Malik
b537f1f82d chore: add paragon messages (#798) 2023-07-21 11:08:29 +05:00
Deborah Kaplan
c42e339051 Merge pull request #800 from openedx/update-browserslist-db
Update browserslist DB
2023-07-20 11:15:12 -04:00
Deborah Kaplan
528bbcbad8 Merge pull request #805 from openedx/dependabot/npm_and_yarn/semver-5.7.2
build(deps): bump semver from 5.7.1 to 5.7.2
2023-07-20 11:02:08 -04:00
renovate[bot]
fcc5ce77e7 fix(deps): update dependency @edx/paragon to v20.45.2 2023-07-17 17:19:14 +00:00
renovate[bot]
ff4cd80ff2 fix(deps): update dependency @edx/frontend-component-header to v4.4.3 2023-07-17 12:24:56 +00:00
dependabot[bot]
25a6093a78 build(deps): bump semver from 5.7.1 to 5.7.2
Bumps [semver](https://github.com/npm/node-semver) from 5.7.1 to 5.7.2.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/v5.7.2/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v5.7.1...v5.7.2)

---
updated-dependencies:
- dependency-name: semver
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-17 09:41:10 +00:00
renovate[bot]
d3a4b4b62d chore(deps): update dependency @edx/frontend-build to v12.8.66 2023-07-17 09:39:54 +00:00
renovate[bot]
fd52682b5c fix(deps): update dependency @edx/frontend-component-footer to v12.1.2 2023-07-17 06:41:39 +00:00
jsnwesson
a6eb5f75d1 chore: update browserslist DB 2023-07-17 00:10:44 +00:00
Emad Rad
6803fb5199 Feature: Persian language (#620)
* feat: Persian translations added

* feat: persian language added to messages

* feat: fa_IR added to transifex_langs
2023-07-12 10:07:50 -07:00
Swayam Rana
f05db64d49 Merge pull request #790 from openedx/swayamrana-pact
feat: Pact Consumer Contract Testing on LMS Account API
2023-07-11 11:22:08 -04:00
Swayam Rana
038c2a845f feat: Pact Contract Consumer testing with the Account API, and implement stub script 2023-07-11 15:15:33 +00:00
renovate[bot]
105486a44a fix(deps): update dependency @edx/frontend-component-header to v4.4.0 2023-07-10 15:51:59 +00:00
renovate[bot]
7b44687db9 fix(deps): update dependency @edx/frontend-component-footer to v12.1.1 2023-07-10 14:36:42 +00:00
renovate[bot]
7aeac7be3b chore(deps): update dependency glob to v10.3.3 2023-07-10 11:08:03 +00:00
renovate[bot]
d4665797a1 chore(deps): update dependency @edx/frontend-build to v12.8.60 2023-07-10 07:14:11 +00:00
Bilal Qamar
4a02f0ef6d feat: update react & react-dom to v17 (#769)
* feat: update react & react-dom to v17
* build: update lock file and paragon
* Merge branch 'master' of github.com:openedx/frontend-app-profile into bilalqamar95/react-upgrade-to-v17
* refactor: updated edx packages
---------

Co-authored-by: mashal-m <mashal.malik@arbisoft.com>
2023-07-07 10:17:53 -04:00
Jason Wesson
b0b8e96025 build: add catalog-info yaml and update README (#797)
* These updates and additions aim to meet the Maintainership Pilot Phase 1 requirements

Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-07-05 12:08:06 -07:00
renovate[bot]
6d5c150d2f chore(deps): update dependency glob to v10 (#793)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-03 10:55:50 -07:00
Eugene Dyudyunov
549d51509b fix: error when trying to save 'other education' (#667)
When user selects "Other Education" and clicks save - error occurs.

It's caused by the wrong API call payload value `'o'`
Fix: use the `'other'` payload value instead (the valid one).
2023-07-03 10:22:13 -07:00
Yoiber
ba74aef631 chore(i18n): add more languages (#689)
* chore(i18n): add more languages
2023-07-03 10:20:05 -07:00
edX requirements bot
941652aba2 chore: update browserslist DB (#781)
Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-07-03 10:15:59 -07:00
renovate[bot]
3b68201748 fix(deps): update dependency @edx/frontend-component-header to v4.2.3 (#743)
* fix(deps): update dependency @edx/frontend-component-header to v4.2.3

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-07-03 10:13:11 -07:00
renovate[bot]
2b8f5a8b3d fix(deps): update dependency history to v5 (#794)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-07-03 10:04:22 -07:00
Maxwell Frank
235644e570 Merge pull request #791 from openedx/mfrank/remove-skills-builder-master
refactor: remove Skills Builder
2023-07-03 09:11:34 -04:00
Maxwell Frank
5e0eef68e3 refactor: remove Skills Builder 2023-07-03 13:00:47 +00:00
renovate[bot]
4692477738 fix(deps): update dependency react-instantsearch-hooks-web to v6.45.0 2023-07-03 06:15:35 +00:00
renovate[bot]
465d3b481a fix(deps): update dependency algoliasearch to v4.18.0 2023-06-26 15:57:05 +00:00
renovate[bot]
79e29c46c5 fix(deps): update dependency @edx/paragon to v20.45.0 2023-06-26 13:32:44 +00:00
renovate[bot]
97e70fa4ab chore(deps): update dependency @edx/frontend-build to v12.8.57 2023-06-26 10:16:08 +00:00
renovate[bot]
60fec4152f chore(deps): update commitlint monorepo to v17.6.6 2023-06-26 07:16:12 +00:00
Maxwell Frank
26a82ec109 Merge pull request #776 from openedx/update-browserslist-db
Update browserslist DB
2023-06-21 13:09:03 -04:00
renovate[bot]
bf03f8b5ef fix(deps): update dependency react-instantsearch-hooks-web to v6.44.3 2023-06-19 17:51:20 +00:00
renovate[bot]
2a4addbb38 fix(deps): update dependency core-js to v3.31.0 2023-06-19 14:19:55 +00:00
renovate[bot]
e2b9ef7a2f fix(deps): update dependency @edx/paragon to v20.44.0 2023-06-19 11:34:11 +00:00
renovate[bot]
a6bcc9c054 chore(deps): update dependency @edx/frontend-build to v12.8.54 2023-06-19 06:49:52 +00:00
jsnwesson
31d2c07c5b chore: update browserslist DB 2023-06-19 00:08:41 +00:00
Jason Wesson
03e352208f fix: switch out class overwrite in SCSS for an aria-label selector (#771)
* fix: switch out class overwrite in SCSS for an aria-label selector

---------

Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-06-15 11:22:46 -07:00
Maxwell Frank
ac7285dac5 Merge pull request #775 from openedx/mfrank/switch-for-lob
Query string to act as switch for lines of business
2023-06-15 10:01:36 -04:00
Maxwell Frank
60fe9cff9a feat: adding lines of business 2023-06-15 13:27:10 +00:00
edX requirements bot
93f757f3d7 chore: update browserslist DB (#699)
Co-authored-by: jsnwesson <jsnwesson@users.noreply.github.com>
2023-06-14 13:36:48 -07:00
renovate[bot]
6740ad3672 fix(deps): update dependency algoliasearch to v4.17.2 2023-06-12 16:25:54 +00:00
renovate[bot]
ca14d3d279 chore(deps): update dependency @edx/frontend-build to v12.8.51 2023-06-12 12:00:55 +00:00
Jenkins
9dbe58b10f chore(i18n): update translations 2023-06-11 16:40:53 -04:00
Jason Wesson
50d80ef614 fix: adjust header height and content when window is medium in width or less (#756)
* fix: adjust header height and content when window is medium in width or less

* feat: convert stepper header's contents to compact view for medium windows (or less)

---------

Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-06-06 11:10:00 -07:00
renovate[bot]
fe6b76da7e fix(deps): update dependency @edx/frontend-platform to v4.5.1 2023-06-05 12:46:05 +00:00
renovate[bot]
7fff13137d chore(deps): update dependency @edx/frontend-build to v12.8.40 2023-06-05 10:26:23 +00:00
renovate[bot]
91282cff74 chore(deps): update commitlint monorepo to v17.6.5 2023-06-05 07:50:24 +00:00
97 changed files with 7434 additions and 5285 deletions

5
.env
View File

@@ -26,9 +26,4 @@ COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL=''
ENABLE_SKILLS_BUILDER=''
ENABLE_SKILLS_BUILDER_PROFILE=''
ALGOLIA_APP_ID=''
ALGOLIA_JOBS_INDEX_NAME=''
ALGOLIA_PRODUCT_INDEX_NAME=''
ALGOLIA_SEARCH_API_KEY=''

View File

@@ -27,9 +27,4 @@ COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL='http://localhost:18000/courses'
ENABLE_SKILLS_BUILDER='true'
ENABLE_SKILLS_BUILDER_PROFILE=''
ALGOLIA_APP_ID=''
ALGOLIA_JOBS_INDEX_NAME=''
ALGOLIA_PRODUCT_INDEX_NAME=''
ALGOLIA_SEARCH_API_KEY=''

View File

@@ -18,13 +18,8 @@ LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
ENABLE_LEARNER_RECORD_MFE=''
ENABLE_SKILLS_BUILDER='true'
ENABLE_SKILLS_BUILDER_PROFILE=''
LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'
COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
ALGOLIA_APP_ID=''
ALGOLIA_JOBS_INDEX_NAME=''
ALGOLIA_PRODUCT_INDEX_NAME=''
ALGOLIA_SEARCH_API_KEY=''

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
uses: openedx/.github/.github/workflows/lockfile-check.yml@master

View File

@@ -1,13 +1,13 @@
export TRANSIFEX_RESOURCE = frontend-app-profile
transifex_resource = frontend-app-profile
transifex_langs = "ar,fr,es_419,zh_CN,pt,it,de,uk,ru,hi,fr_CA"
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
transifex_input = $(i18n)/transifex_input.json
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
transifex_temp = ./temp/babel-plugin-formatjs
NPM_TESTS=build i18n_extract lint test
@@ -61,11 +61,12 @@ pull_translations:
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
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) frontend-component-header frontend-component-footer frontend-app-profile
$(intl_imports) paragon frontend-component-header frontend-component-footer frontend-app-profile
endif
# This target is used by Travis.

View File

@@ -1,57 +1,147 @@
|Build Status| |Codecov| |license|
#####################
frontend-app-profile
====================
#####################
This is a micro-frontend application responsible for the display and updating of user profiles. Please tag **@openedx/2u-aperture** on any PRs or issues.
|license-badge| |status-badge| |ci-badge| |codecov-badge|
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-profile.svg
:target: https://github.com/openedx/frontend-app-profile/blob/main/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
.. |ci-badge| image:: https://github.com/openedx/frontend-app-profile/actions/workflows/ci.yml/badge.svg
:target: https://github.com/openedx/frontend-app-profile/actions/workflows/ci.yml
:alt: Continuous Integration
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-profile/coverage.svg?branch=main
:target: https://codecov.io/github/openedx/frontend-app-profile?branch=main
:alt: Codecov
********
Purpose
********
This is a micro-frontend application responsible for the display and updating of user profiles.
When a user views their own profile, they're given fields to edit their full name, location, primary spoken language, education, social links, and bio. Each field also has a dropdown to select the visibility of that field - i.e., whether it can be viewed by other learners.
When a user views someone else's profile, they see all those fields that that user set as public.
----------
***************
Getting Started
***************
Development
-----------
Installation
============
Start Devstack
^^^^^^^^^^^^^^
Follow these steps to provision, run, and enable an instance of the
Profile MFE for local development via the `devstack`_.
To use this application `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
.. _devstack: https://github.com/openedx/devstack#getting-started
- Start devstack
- Log in (http://localhost:18000/login)
#. To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* Start devstack
* Log in (http://localhost:18000/login)
In this project, install requirements and start the development server by running:
#. To run Profile, install requirements and start the development server by running:
.. code:: bash
.. code-block::
npm install
npm start # The server will run on port 1995
1. Clone your new repo:
Once the dev server is up visit http://localhost:1995/u/staff.
``git clone https://github.com/openedx/frontend-app-profile.git``
----------
2. Use node v18.x.
Configuration and Deployment
----------------------------
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-app-profile && npm ci``
4. Start the dev server:
``npm start``
The server will run on port 1995
Once the dev server is up, visit http://localhost:1995/u/staff.
Configuration
=============
This MFE is configured via node environment variables supplied at build time. See the .env file for the list of required environment variables. Example build syntax with a single environment variable:
.. code:: bash
.. code-block::
NODE_ENV=production ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
Getting Help
============
For more information see the document: `Micro-frontend applications in Open
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/micro-frontends-in-open-edx.html>`__.
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-profile.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-profile
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-profile
:target: https://codecov.io/gh/edx/frontend-app-profile
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-profile.svg
:target: @edx/frontend-app-profile
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide. Please tag **@openedx/2u-aperture** on any PRs or issues.
https://github.com/openedx/frontend-app-profile/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/getting-help
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
The Open edX Code of Conduct
============================
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
======
The assigned maintainers for this component and other project details may be
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
.. _Backstage: https://backstage.herokuapp.com/catalog/default/component/frontend-app-profile
Reporting Security Issues
=========================
Please do not report security issues in public. Email security@openedx.org instead.

24
catalog-info.yaml Normal file
View File

@@ -0,0 +1,24 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'Profile'
description: 'This is a micro-frontend application responsible for the display and updating of user profiles.'
links:
- url: 'https://github.com/openedx/frontend-app-profile/blob/master/README.rst'
title: 'Documentation'
icon: 'Article'
annotations:
# (Optional) Annotation keys and values can be whatever you want.
# We use it in Open edX repos to have a comma-separated list of GitHub user
# names that might be interested in changes to the architecture of this
# component.
openedx.org/arch-interest-groups: ""
# This can be multiple comma-separated projects.
openedx.org/add-to-projects: "openedx:23"
spec:
type: 'service'
lifecycle: 'production'
owner: 2U-aperture
# (Optional) An array of different components or resources.

8323
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,12 @@
},
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests"
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"stubs": "pact-stub-service ./src/pacts/frontend-app-profile-edx-platform.json --port 18000"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-profile/issues"
@@ -27,53 +28,52 @@
"extends @edx/browserslist-config"
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-platform": "4.5.0",
"@edx/paragon": "^20.32.0",
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.6.0",
"@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",
"algoliasearch": "4.17.1",
"@pact-foundation/pact": "^11.0.2",
"classnames": "2.3.2",
"core-js": "3.30.2",
"history": "4.10.1",
"core-js": "3.33.3",
"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": "16.14.0",
"react-dom": "16.14.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-error-boundary": "^4.0.11",
"react-helmet": "6.1.0",
"react-instantsearch-hooks-web": "^6.40.1",
"react-redux": "7.2.9",
"react-router": "5.3.4",
"react-router-dom": "5.3.4",
"react-router": "6.16.0",
"react-router-dom": "6.16.0",
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.2.3",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.13.11",
"regenerator-runtime": "0.14.0",
"reselect": "4.1.8",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@commitlint/cli": "17.6.3",
"@commitlint/config-angular": "17.6.3",
"@commitlint/cli": "17.8.1",
"@commitlint/config-angular": "17.8.1",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "12.8.38",
"@edx/frontend-build": "13.0.8",
"@edx/reactifex": "2.2.0",
"@testing-library/react": "11.2.7",
"codecov": "3.8.3",
"@testing-library/react": "12.1.5",
"@wojtekmaj/enzyme-adapter-react-17": "0.8.0",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"glob": "8.1.0",
"react-test-renderer": "16.14.0",
"glob": "10.3.10",
"react-test-renderer": "17.0.2",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.4"
}

93
plugins/Plugin.jsx Normal file
View File

@@ -0,0 +1,93 @@
'use client';
import React, {
useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import { ErrorBoundary } from 'react-error-boundary';
import { logError } from '@edx/frontend-platform/logging';
import {
dispatchMountedEvent, dispatchReadyEvent, dispatchUnmountedEvent, useHostEvent,
} from './data/hooks';
import { PLUGIN_RESIZE } from './data/constants';
// see example-plugin-app/src/PluginOne.jsx for example of customizing errorFallback
function errorFallbackDefault() {
return (
<div>
<h2>
Oops! An error occurred. Please refresh the screen to try again.
</h2>
</div>
);
}
// eslint-disable-next-line react/function-component-definition
export default function Plugin({
children, className, style, ready, errorFallbackProp,
}) {
const [dimensions, setDimensions] = useState({
width: null,
height: null,
});
const finalStyle = useMemo(() => ({
...dimensions,
...style,
}), [dimensions, style]);
const errorFallback = errorFallbackProp || errorFallbackDefault;
// Error logging function
// Need to confirm: When an error is caught here, the logging will be sent to the child MFE's logging service
const logErrorToService = (error, info) => {
logError(error, { stack: info.componentStack });
};
useHostEvent(PLUGIN_RESIZE, ({ payload }) => {
setDimensions({
width: payload.width,
height: payload.height,
});
});
useEffect(() => {
dispatchMountedEvent();
return () => {
dispatchUnmountedEvent();
};
}, []);
useEffect(() => {
if (ready) {
dispatchReadyEvent();
}
}, [ready]);
return (
<div className={className} style={finalStyle}>
<ErrorBoundary
FallbackComponent={errorFallback}
onError={logErrorToService}
>
{children}
</ErrorBoundary>
</div>
);
}
Plugin.propTypes = {
children: PropTypes.node.isRequired,
className: PropTypes.string,
errorFallbackProp: PropTypes.func,
ready: PropTypes.bool,
style: PropTypes.object, // eslint-disable-line
};
Plugin.defaultProps = {
className: null,
errorFallbackProp: null,
style: {},
ready: true,
};

View File

@@ -0,0 +1,42 @@
'use client';
import React from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import PluginContainerIframe from './PluginContainerIframe';
import {
IFRAME_PLUGIN,
} from './data/constants';
import { pluginConfigShape } from './data/shapes';
// eslint-disable-next-line react/function-component-definition
export default function PluginContainer({ config, ...props }) {
if (config === null) {
return null;
}
// this will allow for future plugin types to be inserted in the PluginErrorBoundary
let renderer = null;
switch (config.type) {
case IFRAME_PLUGIN:
renderer = (
<PluginContainerIframe config={config} {...props} />
);
break;
// istanbul ignore next: default isn't meaningful, just satisfying linter
default:
}
return (
renderer
);
}
PluginContainer.propTypes = {
config: pluginConfigShape,
};
PluginContainer.defaultProps = {
config: null,
};

View File

@@ -0,0 +1,99 @@
import React, {
useEffect, useState,
} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import {
PLUGIN_MOUNTED,
PLUGIN_READY,
PLUGIN_RESIZE,
} from './data/constants';
import {
dispatchPluginEvent,
useElementSize,
usePluginEvent,
} from './data/hooks';
import { pluginConfigShape } from './data/shapes';
/**
* Feature policy for iframe, allowing access to certain courseware-related media.
*
* We must use the wildcard (*) origin for each feature, as courseware content
* may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
* block that iframes external course content.
* This policy was selected in conference with the edX Security Working Group.
* Changes to it should be vetted by them (security@edx.org).
*/
export const IFRAME_FEATURE_POLICY = (
'fullscreen; microphone *; camera *; midi *; geolocation *; encrypted-media *'
);
// eslint-disable-next-line react/function-component-definition
export default function PluginContainerIframe({
config, fallback, className, ...props
}) {
const { url } = config;
const { title, scrolling } = props;
const [mounted, setMounted] = useState(false);
const [ready, setReady] = useState(false);
const [iframeRef, iframeElement, width, height] = useElementSize();
useEffect(() => {
if (mounted) {
dispatchPluginEvent(iframeElement, {
type: PLUGIN_RESIZE,
payload: {
width,
height,
},
}, url);
}
}, [iframeElement, mounted, width, height, url]);
usePluginEvent(iframeElement, PLUGIN_MOUNTED, () => {
setMounted(true);
});
usePluginEvent(iframeElement, PLUGIN_READY, () => {
setReady(true);
});
return (
<>
<iframe
ref={iframeRef}
title={title}
src={url}
allow={IFRAME_FEATURE_POLICY}
scrolling={scrolling}
referrerPolicy="origin" // The sent referrer will be limited to the origin of the referring page: its scheme, host, and port.
className={classNames(
'border border-0',
{ 'd-none': !ready },
className,
)}
{...props}
/>
{!ready && fallback}
</>
);
}
PluginContainerIframe.propTypes = {
config: pluginConfigShape,
fallback: PropTypes.node,
scrolling: PropTypes.oneOf(['auto', 'yes', 'no']),
title: PropTypes.string,
className: PropTypes.string,
};
PluginContainerIframe.defaultProps = {
config: null,
fallback: null,
scrolling: 'auto',
title: null,
className: null,
};

View File

@@ -0,0 +1,45 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
// eslint-disable-next-line import/no-extraneous-dependencies
import { FormattedMessage } from 'react-intl';
import { logError } from '@edx/frontend-platform/logging';
export default class PluginErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}
componentDidCatch(error, info) {
logError(error, { stack: info.componentStack });
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return (
<FormattedMessage
id="plugin.load.failure.text"
defaultMessage="This content failed to load."
description="error message when an unexpected error occurs"
/>
);
}
return this.props.children;
}
}
PluginErrorBoundary.propTypes = {
children: PropTypes.node,
};
PluginErrorBoundary.defaultProps = {
children: null,
};

75
plugins/PluginSlot.jsx Normal file
View File

@@ -0,0 +1,75 @@
/* eslint-disable no-unused-vars */
import React, { forwardRef } from 'react';
import classNames from 'classnames';
import { Spinner } from '@edx/paragon';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
// import { usePluginSlot } from './data/hooks';
import PluginContainer from './PluginContainer';
const PluginSlot = forwardRef(({
as, id, intl, pluginProps, children, ...props
}, ref) => {
/* the plugins below are obtained by the id passed into PluginSlot by the Host MFE. See example/src/PluginsPage.jsx
for an example of how PluginSlot is populated, and example/src/index.jsx for a dummy JS config that holds all plugins
*/
// const { plugins, keepDefault } = usePluginSlot(id);
const { fallback } = pluginProps;
// TODO: Add internationalization to the "Loading" text on the spinner.
let finalFallback = (
<div className={classNames(pluginProps.className, 'd-flex justify-content-center align-items-center')}>
<Spinner animation="border" screenReaderText="Loading" />
</div>
);
if (fallback !== undefined) {
finalFallback = fallback;
}
let finalChildren = [];
// if (plugins.length > 0) {
// if (keepDefault) {
// finalChildren.push(children);
// }
// plugins.forEach((pluginConfig) => {
// finalChildren.push(
// <PluginContainer
// key={pluginConfig.url}
// config={pluginConfig}
// fallback={finalFallback}
// {...pluginProps}
// />,
// );
// });
// } else {
finalChildren = children;
// }
return React.createElement(
as,
{
...props,
ref,
},
finalChildren,
);
});
export default injectIntl(PluginSlot);
PluginSlot.propTypes = {
as: PropTypes.elementType,
children: PropTypes.node,
id: PropTypes.string.isRequired,
intl: intlShape.isRequired,
pluginProps: PropTypes.object, // eslint-disable-line
};
PluginSlot.defaultProps = {
as: 'div',
children: null,
pluginProps: {},
};

View File

@@ -0,0 +1,8 @@
// TODO: We expect other plugin types to be added here, such as LTI_PLUGIN and BUILD_TIME_PLUGIN.
export const IFRAME_PLUGIN = 'IFRAME_PLUGIN'; // loads iframe at the URL, rather than loading a JS file.
// Plugin lifecycle events
export const PLUGIN_MOUNTED = 'PLUGIN_MOUNTED';
export const PLUGIN_READY = 'PLUGIN_READY';
export const PLUGIN_UNMOUNTED = 'PLUGIN_UNMOUNTED';
export const PLUGIN_RESIZE = 'PLUGIN_RESIZE';

96
plugins/data/hooks.js Normal file
View File

@@ -0,0 +1,96 @@
import {
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from 'react';
import { PLUGIN_MOUNTED, PLUGIN_READY, PLUGIN_UNMOUNTED } from './constants';
export function useMessageEvent(srcWindow, type, callback) {
useLayoutEffect(() => {
const listener = (event) => {
// Filter messages to those from our source window.
if (event.source === srcWindow) {
if (event.data.type === type) {
callback({ type, payload: event.data.payload });
}
}
};
if (srcWindow !== null) {
global.addEventListener('message', listener);
}
return () => {
global.removeEventListener('message', listener);
};
}, [srcWindow, type, callback]);
}
export function useHostEvent(type, callback) {
useMessageEvent(global.parent, type, callback);
}
export function usePluginEvent(iframeElement, type, callback) {
const contentWindow = iframeElement ? iframeElement.contentWindow : null;
useMessageEvent(contentWindow, type, callback);
}
export function dispatchMessageEvent(targetWindow, message, targetOrigin) {
// Checking targetOrigin falsiness here since '', null or undefined would all be reasons not to
// try to post a message to the origin.
if (targetOrigin) {
targetWindow.postMessage(message, targetOrigin);
}
}
export function dispatchPluginEvent(iframeElement, message, targetOrigin) {
dispatchMessageEvent(iframeElement.contentWindow, message, targetOrigin);
}
export function dispatchHostEvent(message) {
dispatchMessageEvent(global.parent, message, global.document.referrer);
}
export function dispatchReadyEvent() {
dispatchHostEvent({ type: PLUGIN_READY });
}
export function dispatchMountedEvent() {
dispatchHostEvent({ type: PLUGIN_MOUNTED });
}
export function dispatchUnmountedEvent() {
dispatchHostEvent({ type: PLUGIN_UNMOUNTED });
}
export function useElementSize() {
const observerRef = useRef();
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [element, setElement] = useState(null);
const measuredRef = useCallback(_element => {
setElement(_element);
}, []);
useEffect(() => {
observerRef.current = new ResizeObserver(() => {
if (element) {
setDimensions({
width: element.clientWidth,
height: element.clientHeight,
});
setOffset({
x: element.offsetLeft,
y: element.offsetTop,
});
}
});
if (element) {
observerRef.current.observe(element);
}
}, [element]);
return useMemo(
() => ([measuredRef, element, dimensions.width, dimensions.height, offset.x, offset.y]),
[measuredRef, element, dimensions, offset],
);
}

10
plugins/data/shapes.js Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable import/prefer-default-export */
import PropTypes from 'prop-types';
import { IFRAME_PLUGIN } from './constants';
export const pluginConfigShape = PropTypes.shape({
url: PropTypes.string.isRequired,
type: PropTypes.oneOf([IFRAME_PLUGIN]).isRequired,
// This is a place for us to put any generic props we want to pass to the component. We need it.
props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
});

18
plugins/index.js Normal file
View File

@@ -0,0 +1,18 @@
// export {
// usePluginSlot,
// } from './data/hooks';
export {
default as Plugin,
} from './Plugin';
export {
default as PluginContainer,
} from './PluginContainer';
export {
default as PluginSlot,
} from './PluginSlot';
export {
IFRAME_PLUGIN,
} from './data/constants';
export {
default as PluginErrorBoundary,
} from './PluginErrorBoundary';

View File

@@ -24,7 +24,7 @@
"automerge": true
},
{
"matchPackagePatterns": ["@edx"],
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}

View File

@@ -1,22 +1,27 @@
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 frMessages from './messages/fr.json';
import es419Messages from './messages/es_419.json';
import zhcnMessages from './messages/zh_CN.json';
import ptMessages from './messages/pt.json';
import itMessages from './messages/it.json';
import ukMessages from './messages/uk.json';
import deMessages from './messages/de.json';
import ruMessages from './messages/ru.json';
import hiMessages from './messages/hi.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,
@@ -26,10 +31,14 @@ const appMessages = {
'fr-ca': frCAMessages,
ru: ruMessages,
uk: ukMessages,
'de-de': dedeCAMessages,
'it-it': ititCAMessages,
'pt-pt': ptptCAMessages,
};
export default [
headerMessages,
footerMessages,
paragonMessages,
appMessages,
];

View File

@@ -53,32 +53,5 @@
"profile.notfound.message": "الصفحة التي تبحث عنها غير متوفرة أو هناك خطأ في العنوان. رجاءً تحقق من العنوان و حاول مجدّدًا.",
"profile.viewMyRecords": "عرض سجلّاتي",
"profile.loading": "يتم تحميل الملف الشخصي...",
"profile.username.description": "معلومات ملفك الشخصي تظهر لك فقط. وحده اسم المستخدم الخاص بك يظهر للآخرين على {siteName}.",
"skills.builder.header.title": "باني المهارات",
"skills.builder.header.subheading": "دع (المنصة التعليمية أو edX) ان تكون دليلك",
"go.back.button": "العودة إلى الخلف",
"next.step.button": "الخطوة التالية",
"exit.button": "خروج",
"select.preferences": "حدد التفضيلاتك",
"review.results": "مراجعة النتائج",
"skills.builder.description": "ابحث عن الدورات والبرامج المناسبة التي تساعدك في الوصول إلى أهدافك.",
"learning.goal.prompt": "أولاً، أخبرنا بما تريد تحقيقه",
"select.learning.goal": "اختر هدفًا",
"learning.goal.start_career": "أريد أن أبدأ مسيرتي المهنية",
"learning.goal.advance_career": "أريد أن ارتقي في مهنتي",
"learning.goal.change_career": "اريد تغيير المهنتي",
"learning.goal.something.new": "أريد أن أتعلم شيئًا جديدًا",
"learning.goal.something.else": "شيء آخر",
"job.title.prompt": "بعد ذلك، ابحث وحدد المسمى الوظيفي الحالي الخاص بك",
"job.title.input.placeholder.text": "أبحث واختار مسمى وظيفي",
"student.checkbox.prompt": "أنا طالب",
"currently.looking.checkbox.prompt": "أنا حاليا أبحث عن عمل",
"career.interest.prompt": "ما هي المهن التي تثير اهتمامك؟",
"career.interest.input.placeholder.text": "حدد ما يصل إلى ثلاث عناوين وظيفية جديدة",
"career.interest.remove.button.alt.text": "إزالة الاهتمام الوظيفي:",
"matches.found.success.alert": "وجدنا المهارات والدورات التي تناسب تفضيلاتك!",
"matches.not.found.danger.alert": "لم نتمكن من استرداد التوصيات في هذا الوقت. الرجاء معاودة المحاولة في وقت لاحق.",
"related.skills.heading": "مهارات ذات الصلة",
"related.skills.selectable.box.label.text": "مهارات ذات الصلة:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "معلومات ملفك الشخصي تظهر لك فقط. وحده اسم المستخدم الخاص بك يظهر للآخرين على {siteName}."
}

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
}

View File

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

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Constructor de habilidades",
"skills.builder.header.subheading": "Dejanos ser tu guía",
"go.back.button": "Volver Atrás",
"next.step.button": "Próximo paso",
"exit.button": "Salida",
"select.preferences": "Seleccionar preferencias",
"review.results": "Revisar resultados",
"skills.builder.description": "Encontrar los cursos y programas adecuados que lo ayuden a alcanzar sus metas.",
"learning.goal.prompt": "Primero, contar qué quieres lograr",
"select.learning.goal": "Seleccionar una meta",
"learning.goal.start_career": "Quiero empezar mi carrera",
"learning.goal.advance_career": "Quiero avanzar en mi carrera",
"learning.goal.change_career": "Quiero cambiar de carrera",
"learning.goal.something.new": "Quiero aprender algo nuevo",
"learning.goal.something.else": "Algo más",
"job.title.prompt": "A continuación, busque y seleccione su título de trabajo actual",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "Soy un estudiante",
"currently.looking.checkbox.prompt": "Actualmente estoy buscando trabajo",
"career.interest.prompt": "¿Qué carreras te interesan?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Eliminar interés profesional:",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"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}."
}

View File

@@ -0,0 +1,57 @@
{
"profile.page.title": "پرونده کاربری {siteName}",
"profile.age.details": "برای اشتراک‌گذاری پرونده کاربری خود با سایر یادگیرندگان {siteName}، باید تأیید کنید که بیش از 13 سال سن دارید.",
"profile.age.set.date": "تنظیم تاریخ تولد",
"profile.datejoined.member.since": "عضو شده از {year}",
"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.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}",
"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.notfound.message": "صفحه مورد نظر شما در دسترس نیست یا خطایی در نشانی آن وجود دارد. لطفاً نشانی اینترنتی را بررسی کرده و دوباره امتحان کنید.",
"profile.viewMyRecords": "مشاهده سوابق من",
"profile.loading": "در حال بارگذاری پرونده کاربری...",
"profile.username.description": "اطلاعات پرونده کاربری فقط برای شما قابل مشاهده است. سایرین فقط نام کاربری شما را در {siteName} می‌توانند ببینند."
}

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"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}."
}

View File

@@ -45,7 +45,7 @@
"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": "Supprimer",
"profile.profileavatar.remove.button": "Retirer",
"profile.image.alt.attribute": "avatar de profil",
"profile.profileavatar.change-button": "Modifier",
"profile.sociallinks.add": "Ajouter {network}",
@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Constructeur de compétences",
"skills.builder.header.subheading": "Laissez EDUlib être votre guide",
"go.back.button": "Retour",
"next.step.button": "Prochaine étape",
"exit.button": "Sortie",
"select.preferences": "Sélectionnez les préférences",
"review.results": "Examiner les résultats",
"skills.builder.description": "Trouvez les bons cours et programmes qui vous aideront à atteindre vos objectifs.",
"learning.goal.prompt": "Tout d&#39;abord, dites-nous ce que vous voulez réaliser",
"select.learning.goal": "Sélectionnez un objectif",
"learning.goal.start_career": "Je veux commencer ma carrière",
"learning.goal.advance_career": "Je veux faire progresser ma carrière",
"learning.goal.change_career": "Je veux changer de métier",
"learning.goal.something.new": "Je veux apprendre quelque chose de nouveau",
"learning.goal.something.else": "Autre chose",
"job.title.prompt": "Ensuite, recherchez et sélectionnez votre titre de poste actuel",
"job.title.input.placeholder.text": "Rechercher et sélectionner un intitulé de poste",
"student.checkbox.prompt": "Je suis étudiant.e",
"currently.looking.checkbox.prompt": "Je suis actuellement à la recherche d&#39;un emploi",
"career.interest.prompt": "Quels métiers vous intéressent ?",
"career.interest.input.placeholder.text": "Sélectionnez jusqu'à 3 nouveaux intitulés de poste",
"career.interest.remove.button.alt.text": "Supprimer l'intérêt professionnel :",
"matches.found.success.alert": "Nous avons trouvé des compétences et des cours qui correspondent à vos préférences !",
"matches.not.found.danger.alert": "Nous n'avons pas pu récupérer les recommandations pour le moment. Veuillez réessayer plus tard.",
"related.skills.heading": "Compétences connexes",
"related.skills.selectable.box.label.text": "Compétences connexes:",
"product.recommendations.header.text": "{productType} recommandations pour {jobName}"
"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}."
}

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
}

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
}

View File

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

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
}

View File

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

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
}

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
}

View File

@@ -53,32 +53,5 @@
"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}.",
"skills.builder.header.title": "Skills Builder",
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit",
"select.preferences": "Select preferences",
"review.results": "Review results",
"skills.builder.description": "Find the right courses and programs that help you reach your goals.",
"learning.goal.prompt": "First, tell us what you want to achieve",
"select.learning.goal": "Select a goal",
"learning.goal.start_career": "I want to start my career",
"learning.goal.advance_career": "I want to advance my career",
"learning.goal.change_career": "I want to change careers",
"learning.goal.something.new": "I want to learn something new",
"learning.goal.something.else": "Something else",
"job.title.prompt": "Next, search and select your current job title",
"job.title.input.placeholder.text": "Search and select a job title",
"student.checkbox.prompt": "I'm a student",
"currently.looking.checkbox.prompt": "I'm currently looking for work",
"career.interest.prompt": "What careers are you interested in?",
"career.interest.input.placeholder.text": "Select up to 3 new job titles",
"career.interest.remove.button.alt.text": "Remove career interest: ",
"matches.found.success.alert": "We found skills and courses that match your preferences!",
"matches.not.found.danger.alert": "We were not able to retrieve recommendations at this time. Please try again later.",
"related.skills.heading": "Related Skills",
"related.skills.selectable.box.label.text": "Related skills:",
"product.recommendations.header.text": "{productType} recommendations for {jobName}"
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
}

View File

@@ -15,6 +15,7 @@ import {
import React from 'react';
import ReactDOM from 'react-dom';
import { useLocation } from 'react-router-dom';
import Header from '@edx/frontend-component-header';
import Footer from '@edx/frontend-component-footer';
@@ -27,15 +28,25 @@ import Head from './head/Head';
import AppRoutes from './routes/AppRoutes';
const RenderFooter = () => {
const location = useLocation();
return location.pathname.includes('/plugin') ? null : <Footer />;
};
const RenderHeader = () => {
const location = useLocation();
return location.pathname.includes('/plugin') ? null : <Header />;
};
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={configureStore()}>
<Head />
<Header />
<main>
<RenderHeader />
<main id="main">
<AppRoutes />
</main>
<Footer />
<RenderFooter />
</AppProvider>,
document.getElementById('root'),
);
@@ -52,13 +63,7 @@ initialize({
config: () => {
mergeConfig({
COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH,
ENABLE_SKILLS_BUILDER: process.env.ENABLE_SKILLS_BUILDER,
ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE,
ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || null,
ALGOLIA_JOBS_INDEX_NAME: process.env.ALGOLIA_JOBS_INDEX_NAME || null,
ALGOLIA_PRODUCT_INDEX_NAME: process.env.ALGOLIA_PRODUCT_INDEX_NAME || null,
ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || null,
MARKETING_SITE_SEARCH_URL: process.env.SEARCH_CATALOG_URL || null,
}, 'App loadConfig override handler');
},
},

View File

@@ -6,6 +6,3 @@
@import "~@edx/frontend-component-footer/dist/footer";
@import './profile/index';
@import './skills-builder/skills-builder-modal/skillsBuilderModal.scss';
@import './skills-builder/skills-builder-header/skillsBuilderHeader.scss';

View File

@@ -0,0 +1,77 @@
{
"consumer": {
"name": "frontend-app-profile"
},
"interactions": [
{
"description": "A request for user's basic information",
"providerStates": [
{
"name": "Account and user's information does not exist"
}
],
"request": {
"method": "GET",
"path": "/api/user/v1/accounts/staff_not_found"
},
"response": {
"status": 404
}
},
{
"description": "A request for user's basic information",
"providerStates": [
{
"name": "I have a user's basic information"
}
],
"request": {
"method": "GET",
"path": "/api/user/v1/accounts/staff"
},
"response": {
"body": {
"bio": "This is my bio",
"country": "ME",
"dateJoined": "2017-06-07T00:44:23Z",
"email": "staff@example.com",
"isActive": true,
"name": "Lemon Seltzer",
"username": "staff",
"yearOfBirth": 1901
},
"headers": {
"Content-Type": "application/json"
},
"matchingRules": {
"body": {
"$": {
"combine": "AND",
"matchers": [
{
"match": "type"
}
]
}
}
},
"status": 200
}
}
],
"metadata": {
"pact-js": {
"version": "11.0.2"
},
"pactRust": {
"ffi": "0.4.0",
"models": "1.0.4"
},
"pactSpecification": {
"version": "3.0.0"
}
},
"provider": {
"name": "edx-platform"
}
}

View File

@@ -6,7 +6,6 @@ const DateJoined = ({ date }) => {
if (date == null) {
return null;
}
return (
<p className="mb-0">
<FormattedMessage

View File

@@ -41,6 +41,8 @@ import { profilePageSelector } from './data/selectors';
// i18n
import messages from './ProfilePage.messages';
import withParams from '../utils/hoc';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
class ProfilePage extends React.Component {
@@ -63,9 +65,9 @@ class ProfilePage extends React.Component {
}
componentDidMount() {
this.props.fetchProfile(this.props.match.params.username);
this.props.fetchProfile(this.props.params.username);
sendTrackingLogEvent('edx.profile.viewed', {
username: this.props.match.params.username,
username: this.props.params.username,
});
}
@@ -102,7 +104,7 @@ class ProfilePage extends React.Component {
}
isAuthenticatedUserProfile() {
return this.props.match.params.username === this.context.authenticatedUser.username;
return this.props.params.username === this.context.authenticatedUser.username;
}
// Inserted into the DOM in two places (for responsive layout)
@@ -124,7 +126,7 @@ class ProfilePage extends React.Component {
return (
<span data-hj-suppress>
<h1 className="h2 mb-0 font-weight-bold">{this.props.match.params.username}</h1>
<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" />
@@ -176,6 +178,7 @@ class ProfilePage extends React.Component {
visibilityLearningGoal,
languageProficiencies,
visibilityLanguageProficiencies,
courseCertificates,
visibilityCourseCertificates,
bio,
visibilityBio,
@@ -194,6 +197,17 @@ class ProfilePage extends React.Component {
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">
@@ -210,7 +224,8 @@ class ProfilePage extends React.Component {
/>
</div>
</div>
<div className="col pl-0">
<div>PluginPOC</div>
<div className="col">
<div className="d-md-none">
{this.renderHeadingLockup()}
</div>
@@ -228,46 +243,58 @@ class ProfilePage extends React.Component {
<div className="d-md-none mb-4">
{this.renderViewMyRecordsButton()}
</div>
<Name
name={name}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
<PreferredLanguage
languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
{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()}
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
{isBioBlockVisible && (
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
)}
{getConfig().ENABLE_SKILLS_BUILDER_PROFILE && (
<LearningGoal
learningGoal={learningGoal}
@@ -276,11 +303,13 @@ class ProfilePage extends React.Component {
{...commonFormProps}
/>
)}
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
{isCertificatesBlockVisible && (
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
)}
</div>
</div>
</div>
@@ -370,10 +399,8 @@ ProfilePage.propTypes = {
updateDraft: PropTypes.func.isRequired,
// Router
match: PropTypes.shape({
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
// i18n
@@ -410,4 +437,4 @@ export default connect(
closeForm,
updateDraft,
},
)(injectIntl(ProfilePage));
)(injectIntl(withParams(ProfilePage)));

View File

@@ -29,7 +29,7 @@ const requiredProfilePageProps = {
deleteProfilePhoto: () => {},
openField: () => {},
closeField: () => {},
match: { params: { username: 'staff' } },
params: { username: 'staff' },
};
// Mock language cookie
@@ -67,29 +67,28 @@ beforeEach(() => {
});
const ProfilePageWrapper = ({
contextValue, store, match, requiresParentalConsent,
contextValue, store, params, requiresParentalConsent,
}) => (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={store}>
<ProfilePage {...requiredProfilePageProps} match={match} requiresParentalConsent={requiresParentalConsent} />
<ProfilePage {...requiredProfilePageProps} params={params} requiresParentalConsent={requiresParentalConsent} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
ProfilePageWrapper.defaultProps = {
match: { params: { username: 'staff' } },
params: { username: 'staff' },
requiresParentalConsent: null,
};
ProfilePageWrapper.propTypes = {
contextValue: PropTypes.shape({}).isRequired,
store: PropTypes.shape({}).isRequired,
match: PropTypes.shape({}),
params: PropTypes.shape({}),
requiresParentalConsent: PropTypes.bool,
};
@@ -115,15 +114,33 @@ describe('<ProfilePage />', () => {
expect(tree).toMatchSnapshot();
});
it('viewing other profile', () => {
it('viewing other profile with all fields', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.viewOtherProfile)}
store={mockStore({
...storeMocks.viewOtherProfile,
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',
},
},
})}
match={{ params: { username: 'verified' } }} // Override default match
/>
);
@@ -279,7 +296,7 @@ describe('<ProfilePage />', () => {
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.loadingApp)}
match={{ params: { username: 'test-username' } }}
params={{ username: 'test-username' }}
/>
);
const wrapper = mount(component);

View File

@@ -0,0 +1,219 @@
/* eslint-disable react/prop-types */
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { ensureConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import { injectIntl, intlShape, FormattedDate } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
import {
ActionRow, Avatar, Card, Hyperlink, Icon,
} from '@edx/paragon';
import { HistoryEdu, VerifiedUser } from '@edx/paragon/icons';
import get from 'lodash.get';
import PluginCountry from './forms/PluginCountry';
import { Plugin } from '../../plugins';
// Actions
import {
fetchProfile,
} from './data/actions';
// Components
import PageLoading from './PageLoading';
// Selectors
import { profilePageSelector } from './data/selectors';
// i18n
import messages from './ProfilePage.messages';
import eduMessages from './forms/Education.messages';
import withParams from '../utils/hoc';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
// eslint-disable-next-line react/function-component-definition
function Fallback() {
return (
<div>this is broken as all get</div>
);
}
const platformDisplayInfo = {
facebook: {
icon: faFacebook,
name: '',
},
twitter: {
icon: faTwitter,
name: '',
},
linkedin: {
icon: faLinkedin,
name: '',
},
};
class ProfilePluginPage extends React.Component {
componentDidMount() {
this.props.fetchProfile(this.props.params.username);
}
renderContent() {
const {
profileImage,
country,
levelOfEducation,
socialLinks,
isLoadingProfile,
dateJoined,
name,
intl,
} = this.props;
if (isLoadingProfile) {
return <PageLoading srMessage={this.props.intl.formatMessage(messages['profile.loading'])} />;
}
return (
<Plugin fallbackComponent={<Fallback />}>
<Card className="mb-2">
<Card.Header
className="pb-5"
subtitle={(
<Hyperlink destination={`/u/${this.props.params.username}`} target="_parent">
View public profile
</Hyperlink>
)}
actions={
(
<ActionRow className="mt-3">
{socialLinks
.filter(({ socialLink }) => Boolean(socialLink))
.map(({ platform, socialLink }) => (
<StaticListItem
key={platform}
name={platformDisplayInfo[platform].name}
url={socialLink}
platform={platform}
/>
))}
</ActionRow>
)
}
/>
<Card.Section className="text-center" muted>
<Avatar
size="xl"
className="profile-plugin-avatar"
src={profileImage.src}
alt="Profile image"
/>
<p className="h2 mb-0 font-weight-bold">{name}</p>
<p className="h3 mb-0 font-weight-bold">{this.props.params.username}</p>
<PluginCountry
country={country}
/>
</Card.Section>
<Card.Footer className="p-0">
<Card.Section className="pgn-icons-cell-vertical">
<Icon src={VerifiedUser} />
<p>
since <FormattedDate value={new Date(dateJoined)} year="numeric" />
</p>
</Card.Section>
<Card.Section className="pgn-icons-cell-vertical">
<Icon src={HistoryEdu} />
<p>
{intl.formatMessage(get(
eduMessages,
`profile.education.levels.${levelOfEducation}`,
eduMessages['profile.education.levels.o'],
))}
</p>
</Card.Section>
</Card.Footer>
</Card>
</Plugin>
);
}
render() {
return (
<div className="profile-page overflow-hidden">
{this.renderContent()}
</div>
);
}
}
const SocialLink = ({ url, name, platform }) => (
<a href={url} className="font-weight-bold" target="_parent">
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
{name}
</a>
);
const StaticListItem = ({ url, name, platform }) => (
<ul className="list-inline">
<SocialLink name={name} url={url} platform={platform} />
</ul>
);
ProfilePluginPage.contextType = AppContext;
ProfilePluginPage.propTypes = {
// Account data
dateJoined: PropTypes.string,
// Country form data
country: PropTypes.string,
// Education form data
levelOfEducation: PropTypes.string,
// Social links form data
socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
// Other data we need
profileImage: PropTypes.shape({
src: PropTypes.string,
isDefault: PropTypes.bool,
}),
isLoadingProfile: PropTypes.bool.isRequired,
// Actions
fetchProfile: PropTypes.func.isRequired,
// Router
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
// i18n
intl: intlShape.isRequired,
};
ProfilePluginPage.defaultProps = {
profileImage: {},
levelOfEducation: null,
country: null,
socialLinks: [],
dateJoined: null,
};
export default connect(
profilePageSelector,
{
fetchProfile,
},
)(injectIntl(withParams(ProfilePluginPage)));

View File

@@ -103,8 +103,11 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -113,7 +116,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -163,7 +166,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -191,7 +194,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -241,7 +244,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -1649,7 +1652,9 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
id="country-2"
>
country error
<div>
country error
</div>
</div>
</div>
<div
@@ -1694,7 +1699,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
</svg>
</span>
<select
className="d-inline-block w-auto form-control"
className="d-inline-block form-control"
id="visibilityCountry"
name="visibilityCountry"
onChange={[Function]}
@@ -1745,7 +1750,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 116.122 3.91l1.176 1.618A8 8 0 1020 12h2z"
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
@@ -2387,7 +2392,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -2484,8 +2489,11 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -2494,7 +2502,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -2544,7 +2552,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -2572,7 +2580,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -2622,7 +2630,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -2995,7 +3003,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
No formal education
</option>
<option
value="o"
value="other"
>
Other education
</option>
@@ -3004,7 +3012,9 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
id="levelOfEducation-3"
>
education error
<div>
education error
</div>
</div>
</div>
<div
@@ -3049,7 +3059,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
</svg>
</span>
<select
className="d-inline-block w-auto form-control"
className="d-inline-block form-control"
id="visibilityLevelOfEducation"
name="visibilityLevelOfEducation"
onChange={[Function]}
@@ -3100,7 +3110,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 116.122 3.91l1.176 1.618A8 8 0 1020 12h2z"
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
@@ -3562,7 +3572,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -3659,8 +3669,11 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -3669,7 +3682,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -3719,7 +3732,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -3747,7 +3760,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -3797,7 +3810,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -4964,7 +4977,9 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
id="languageProficiencies-4"
>
preferred language error
<div>
preferred language error
</div>
</div>
</div>
<div
@@ -5009,7 +5024,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
</svg>
</span>
<select
className="d-inline-block w-auto form-control"
className="d-inline-block form-control"
id="visibilityLanguageProficiencies"
name="visibilityLanguageProficiencies"
onChange={[Function]}
@@ -5060,7 +5075,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 116.122 3.91l1.176 1.618A8 8 0 1020 12h2z"
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
@@ -5612,7 +5627,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -5637,7 +5652,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
</div>
`;
exports[`<ProfilePage /> Renders correctly in various states viewing other profile 1`] = `
exports[`<ProfilePage /> Renders correctly in various states viewing other profile with all fields 1`] = `
<div
className="profile-page"
>
@@ -5686,8 +5701,11 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -5696,9 +5714,9 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
verified
staff
</h1>
<p
className="mb-0"
@@ -5723,7 +5741,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 6.5c2.76 0 5 2.24 5 5 0 .51-.1 1-.24 1.46l3.06 3.06c1.39-1.23 2.49-2.77 3.18-4.53C21.27 7.11 17 4 12 4c-1.27 0-2.49.2-3.64.57l2.17 2.17c.47-.14.96-.24 1.47-.24zM3.42 2.45L2.01 3.87l2.68 2.68A11.738 11.738 0 001 11.5C2.73 15.89 7 19 12 19c1.52 0 2.97-.3 4.31-.82l3.43 3.43 1.41-1.41L3.42 2.45zM12 16.5c-2.76 0-5-2.24-5-5 0-.77.18-1.5.49-2.14l1.57 1.57c-.03.18-.06.37-.06.57 0 1.66 1.34 3 3 3 .2 0 .38-.03.57-.07L14.14 16c-.65.32-1.37.5-2.14.5zm2.97-5.33a2.97 2.97 0 00-2.64-2.64l2.64 2.64z"
d="M12 6.5c2.76 0 5 2.24 5 5 0 .51-.1 1-.24 1.46l3.06 3.06c1.39-1.23 2.49-2.77 3.18-4.53C21.27 7.11 17 4 12 4c-1.27 0-2.49.2-3.64.57l2.17 2.17c.47-.14.96-.24 1.47-.24ZM3.42 2.45 2.01 3.87l2.68 2.68A11.738 11.738 0 0 0 1 11.5C2.73 15.89 7 19 12 19c1.52 0 2.97-.3 4.31-.82l3.43 3.43 1.41-1.41L3.42 2.45ZM12 16.5c-2.76 0-5-2.24-5-5 0-.77.18-1.5.49-2.14l1.57 1.57c-.03.18-.06.37-.06.57 0 1.66 1.34 3 3 3 .2 0 .38-.03.57-.07L14.14 16c-.65.32-1.37.5-2.14.5Zm2.97-5.33a2.97 2.97 0 0 0-2.64-2.64l2.64 2.64Z"
fill="currentColor"
/>
</svg>
@@ -5741,7 +5759,52 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
</div>
<div
className="d-none d-md-block float-right"
/>
>
<a
className="pgn__hyperlink default-link standalone-link btn btn-primary"
href="http://localhost:18150/records"
onClick={[Function]}
rel="noopener noreferrer"
target="_blank"
>
View My Records
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
</span>
</a>
</div>
</div>
</div>
<div
@@ -5757,9 +5820,9 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
verified
staff
</h1>
<p
className="mb-0"
@@ -5784,7 +5847,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 6.5c2.76 0 5 2.24 5 5 0 .51-.1 1-.24 1.46l3.06 3.06c1.39-1.23 2.49-2.77 3.18-4.53C21.27 7.11 17 4 12 4c-1.27 0-2.49.2-3.64.57l2.17 2.17c.47-.14.96-.24 1.47-.24zM3.42 2.45L2.01 3.87l2.68 2.68A11.738 11.738 0 001 11.5C2.73 15.89 7 19 12 19c1.52 0 2.97-.3 4.31-.82l3.43 3.43 1.41-1.41L3.42 2.45zM12 16.5c-2.76 0-5-2.24-5-5 0-.77.18-1.5.49-2.14l1.57 1.57c-.03.18-.06.37-.06.57 0 1.66 1.34 3 3 3 .2 0 .38-.03.57-.07L14.14 16c-.65.32-1.37.5-2.14.5zm2.97-5.33a2.97 2.97 0 00-2.64-2.64l2.64 2.64z"
d="M12 6.5c2.76 0 5 2.24 5 5 0 .51-.1 1-.24 1.46l3.06 3.06c1.39-1.23 2.49-2.77 3.18-4.53C21.27 7.11 17 4 12 4c-1.27 0-2.49.2-3.64.57l2.17 2.17c.47-.14.96-.24 1.47-.24ZM3.42 2.45 2.01 3.87l2.68 2.68A11.738 11.738 0 0 0 1 11.5C2.73 15.89 7 19 12 19c1.52 0 2.97-.3 4.31-.82l3.43 3.43 1.41-1.41L3.42 2.45ZM12 16.5c-2.76 0-5-2.24-5-5 0-.77.18-1.5.49-2.14l1.57 1.57c-.03.18-.06.37-.06.57 0 1.66 1.34 3 3 3 .2 0 .38-.03.57-.07L14.14 16c-.65.32-1.37.5-2.14.5Zm2.97-5.33a2.97 2.97 0 0 0-2.64-2.64l2.64 2.64Z"
fill="currentColor"
/>
</svg>
@@ -5802,7 +5865,52 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
</div>
<div
className="d-md-none mb-4"
/>
>
<a
className="pgn__hyperlink default-link standalone-link btn btn-primary"
href="http://localhost:18150/records"
onClick={[Function]}
rel="noopener noreferrer"
target="_blank"
>
View My Records
<span
className="pgn__hyperlink__external-icon"
title="Opens in a new tab"
>
<span
className="pgn__icon"
style={
Object {
"height": "1em",
"width": "1em",
}
}
>
<svg
aria-hidden={true}
fill="none"
focusable={false}
height={24}
role="img"
viewBox="0 0 24 24"
width={24}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
<span
className="sr-only"
>
in a new tab
</span>
</span>
</span>
</a>
</div>
<div
className="pgn-transition-replace-group position-relative mb-5"
style={
@@ -5810,7 +5918,32 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
"height": null,
}
}
/>
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="editable-item-header mb-2"
>
<h2
className="edit-section-header"
id={null}
>
Full Name
</h2>
</div>
<p
className="h5"
data-hj-suppress={true}
>
user
</p>
</div>
</div>
<div
className="pgn-transition-replace-group position-relative mb-5"
style={
@@ -5818,7 +5951,30 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
"height": null,
}
}
/>
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="editable-item-header mb-2"
>
<h2
className="edit-section-header"
id={null}
>
Location
</h2>
</div>
<p
className="h5"
data-hj-suppress={true}
/>
</div>
</div>
<div
className="pgn-transition-replace-group position-relative mb-5"
style={
@@ -5826,7 +5982,30 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
"height": null,
}
}
/>
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="editable-item-header mb-2"
>
<h2
className="edit-section-header"
id={null}
>
Primary Language Spoken
</h2>
</div>
<p
className="h5"
data-hj-suppress={true}
/>
</div>
</div>
<div
className="pgn-transition-replace-group position-relative mb-5"
style={
@@ -5834,7 +6013,32 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
"height": null,
}
}
/>
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="editable-item-header mb-2"
>
<h2
className="edit-section-header"
id={null}
>
Education
</h2>
</div>
<p
className="h5"
data-hj-suppress={true}
>
Other education
</p>
</div>
</div>
<div
className="pgn-transition-replace-group position-relative mb-5"
style={
@@ -5842,7 +6046,29 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
"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"
/>
</div>
</div>
</div>
<div
className="pt-md-3 col-md-8 col-lg-7 offset-lg-1"
@@ -5854,7 +6080,32 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
"height": null,
}
}
/>
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="editable-item-header mb-2"
>
<h2
className="edit-section-header"
id={null}
>
About Me
</h2>
</div>
<p
className="lead"
data-hj-suppress={true}
>
bio
</p>
</div>
</div>
<div
className="pgn-transition-replace-group position-relative mb-4"
style={
@@ -5862,7 +6113,27 @@ exports[`<ProfilePage /> Renders correctly in various states viewing other profi
"height": null,
}
}
/>
>
<div
style={
Object {
"padding": ".1px 0",
}
}
>
<div
className="editable-item-header mb-2"
>
<h2
className="edit-section-header"
id={null}
>
My Certificates
</h2>
</div>
You don't have any certificates yet.
</div>
</div>
</div>
</div>
</div>
@@ -5941,8 +6212,11 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -5951,7 +6225,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -6001,7 +6275,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -6029,7 +6303,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -6079,7 +6353,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -6894,7 +7168,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -6991,8 +7265,11 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -7001,7 +7278,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -7051,7 +7328,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -7079,7 +7356,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -7129,7 +7406,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -7767,7 +8044,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
</svg>
</span>
<select
className="d-inline-block w-auto form-control"
className="d-inline-block form-control"
id="visibilityBio"
name="visibilityBio"
onChange={[Function]}
@@ -7818,7 +8095,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 116.122 3.91l1.176 1.618A8 8 0 1020 12h2z"
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
@@ -8011,7 +8288,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -8108,8 +8385,11 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -8118,7 +8398,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -8168,7 +8448,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -8196,7 +8476,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -8246,7 +8526,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -8845,7 +9125,9 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
className="pgn__form-control-description pgn__form-text pgn__form-text-invalid"
id="bio-1"
>
bio error
<div>
bio error
</div>
</div>
</div>
<div
@@ -8890,7 +9172,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
</svg>
</span>
<select
className="d-inline-block w-auto form-control"
className="d-inline-block form-control"
id="visibilityBio"
name="visibilityBio"
onChange={[Function]}
@@ -8941,7 +9223,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 116.122 3.91l1.176 1.618A8 8 0 1020 12h2z"
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
@@ -9134,7 +9416,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>
@@ -9231,8 +9513,11 @@ exports[`<ProfilePage /> Renders correctly in various states without credentials
</div>
</div>
</div>
<div>
PluginPOC
</div>
<div
className="col pl-0"
className="col"
>
<div
className="d-md-none"
@@ -9241,7 +9526,7 @@ exports[`<ProfilePage /> Renders correctly in various states without credentials
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -9274,7 +9559,7 @@ exports[`<ProfilePage /> Renders correctly in various states without credentials
data-hj-suppress={true}
>
<h1
className="h2 mb-0 font-weight-bold"
className="h2 mb-0 font-weight-bold text-truncate"
>
staff
</h1>
@@ -10094,7 +10379,7 @@ exports[`<ProfilePage /> Renders correctly in various states without credentials
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19 19H5V5h7V3H3v18h18v-9h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z"
d="M19 19H5V5h7V3H3v18h18v-9h-2v7ZM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7Z"
fill="currentColor"
/>
</svg>

View File

@@ -7,7 +7,7 @@ const EDUCATION_LEVELS = [
'jhs',
'el',
'none',
'o',
'other',
];
const SOCIAL = {

View File

@@ -0,0 +1,80 @@
// This test file simply creates a contract that defines
// expectations and correct responses from the Pact stub server.
import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { initializeMockApp, getConfig, setConfig } from '@edx/frontend-platform';
import { getAccount } from './services';
const expectedUserInfo200 = {
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
dateJoined: '2017-06-07T00:44:23Z',
isActive: true,
yearOfBirth: 1901,
};
const provider = new PactV3({
log: path.resolve(process.cwd(), 'src/pact-logs/pact.log'),
dir: path.resolve(process.cwd(), 'src/pacts'),
consumer: 'frontend-app-profile',
provider: 'edx-platform',
});
describe('getAccount for one username', () => {
beforeAll(async () => {
initializeMockApp();
});
it('returns a HTTP 200 and user information', async () => {
const username200 = 'staff';
await provider.addInteraction({
states: [{ description: "I have a user's basic information" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username200}`,
headers: {},
},
willRespondWith: {
status: 200,
headers: {},
body: MatchersV3.like(expectedUserInfo200),
},
});
return provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
const response = await getAccount(username200);
expect(response).toEqual(expectedUserInfo200);
});
});
it('Account does not exist', async () => {
const username404 = 'staff_not_found';
await provider.addInteraction({
states: [{ description: "Account and user's information does not exist" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username404}`,
},
willRespondWith: {
status: 404,
},
});
await provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
await expect(getAccount(username404).then((response) => response.data)).rejects.toThrow('Request failed with status code 404');
});
});
});

View File

@@ -66,6 +66,25 @@ export function* handleFetchProfile(action) {
} else {
[account, courseCertificates] = 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',
'visibility.name': 'all_users',
'visibility.bio': 'all_users',
'visibility.course_certificates': 'all_users',
'visibility.country': 'all_users',
'visibility.date_joined': 'all_users',
'visibility.level_of_education': 'all_users',
'visibility.language_proficiencies': 'all_users',
'visibility.social_links': 'all_users',
'visibility.time_zone': 'all_users',
});
}
yield put(fetchProfileSuccess(
account,
preferences,

View File

@@ -35,9 +35,12 @@ export const editableFormModeSelector = createSelector(
// 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 or if
// If this isn't the current user's profile
if (!isAuthenticatedUserProfile) {
return 'static';
}
// the current user has no age set / under 13 ...
if (!isAuthenticatedUserProfile || account.requiresParentalConsent) {
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.
@@ -228,13 +231,13 @@ export const visibilitiesSelector = createSelector(
switch (accountPrivacy) {
case 'custom':
return {
visibilityBio: preferences.visibilityBio || 'private',
visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'private',
visibilityCountry: preferences.visibilityCountry || 'private',
visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'private',
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'private',
visibilityName: preferences.visibilityName || 'private',
visibilitySocialLinks: preferences.visibilitySocialLinks || 'private',
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',
visibilityName: preferences.visibilityName || 'all_users',
visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users',
};
case 'private':
return {

View File

@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { LocationOn } from '@edx/paragon/icons';
// Selectors
import { countrySelector } from '../data/selectors';
// eslint-disable-next-line react/prefer-stateless-function
class PluginCountry extends React.Component {
render() {
const {
country,
countryMessages,
} = this.props;
return (
<div className="pgn-icons-cell-horizontal">
<Icon src={LocationOn} />
<p className="h5 mt-1 ml-1">{countryMessages[country]}</p>
</div>
);
}
}
PluginCountry.propTypes = {
country: PropTypes.string,
countryMessages: PropTypes.objectOf(PropTypes.string).isRequired,
};
PluginCountry.defaultProps = {
country: null,
};
export default connect(
countrySelector,
{},
)(injectIntl(PluginCountry));

View File

@@ -119,7 +119,7 @@ exports[`<SocialLinks /> calls social links with edit mode bio 1`] = `
</svg>
</span>
<select
className="d-inline-block w-auto form-control"
className="d-inline-block form-control"
id="visibilitySocialLinks"
name="visibilitySocialLinks"
onChange={[Function]}
@@ -170,7 +170,7 @@ exports[`<SocialLinks /> calls social links with edit mode bio 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 116.122 3.91l1.176 1.618A8 8 0 1020 12h2z"
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>

View File

@@ -39,7 +39,7 @@ const VisibilitySelect = ({ intl, className, ...props }) => {
<span className="d-inline-block ml-1 mr-2" style={{ width: '1.5rem' }}>
<FontAwesomeIcon icon={icon} />
</span>
<select className="d-inline-block w-auto form-control" {...props}>
<select className="d-inline-block form-control" {...props}>
<option key="private" value="private">
{intl.formatMessage(messages['profile.visibility.who.just.me'])}
</option>

View File

@@ -3,3 +3,4 @@ export { default as saga } from './data/sagas';
export { default as ProfilePage } from './ProfilePage';
export { default as NotFoundPage } from './NotFoundPage';
export { default as messages } from './ProfilePage.messages';
export { default as ProfilePluginPage } from './ProfilePluginPage';

View File

@@ -162,4 +162,28 @@
position: relative;
}
}
.pgn-icons-cell-vertical {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 1px;
}
.pgn-icons-cell-horizontal {
display: flex;
flex-direction: row;
justify-content: center;
margin: 1px;
}
.profile-plugin-avatar {
@include media-breakpoint-up(xs) {
max-width: 12rem;
margin-right: 0;
margin-top: -4rem;
margin-bottom: 1rem;
}
}
}

View File

@@ -1,22 +1,18 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
AuthenticatedPageRoute,
PageRoute,
PageWrap,
} from '@edx/frontend-platform/react';
import { Switch } from 'react-router-dom';
import { ProfilePage, NotFoundPage } from '../profile';
import { SkillsBuilder } from '../skills-builder';
import { Routes, Route } from 'react-router-dom';
import { ProfilePage, NotFoundPage, ProfilePluginPage } from '../profile';
const AppRoutes = () => (
<Switch>
{getConfig().ENABLE_SKILLS_BUILDER && (
<PageRoute path="/skills" component={SkillsBuilder} />
)}
<AuthenticatedPageRoute path="/u/:username" component={ProfilePage} />
<PageRoute path="/notfound" component={NotFoundPage} />
<PageRoute path="*" component={NotFoundPage} />
</Switch>
<Routes>
<Route path="/u/:username" element={<AuthenticatedPageRoute><ProfilePage /></AuthenticatedPageRoute>} />
<Route path="/u/:username/plugin" element={<AuthenticatedPageRoute><ProfilePluginPage /></AuthenticatedPageRoute>} />
<Route path="/notfound" element={<PageWrap><NotFoundPage /></PageWrap>} />
<Route path="*" element={<PageWrap><NotFoundPage /></PageWrap>} />
</Routes>
);
export default AppRoutes;

View File

@@ -1,9 +1,8 @@
import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig } from '@edx/frontend-platform';
import { Router } from 'react-router';
import { MemoryRouter as Router } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import AppRoutes from './AppRoutes';
@@ -13,24 +12,15 @@ jest.mock('@edx/frontend-platform/auth', () => ({
getLoginRedirectUrl: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(() => ({
ENABLE_SKILLS_BUILDER: true,
})),
}));
jest.mock('../profile', () => ({
ProfilePage: () => (<div>Profile page</div>),
NotFoundPage: () => (<div>Not found page</div>),
ProfilePluginPage: () => (<div>Plugin page</div>),
}));
jest.mock('../skills-builder', () => ({
SkillsBuilder: () => (<div>Skills Builder</div>),
}));
const RoutesWithProvider = (context, history) => (
const RoutesWithProvider = (context, path) => (
<AppContext.Provider value={context}>
<Router history={history}>
<Router initialEntries={[`${path}`]}>
<AppRoutes />
</Router>
</AppContext.Provider>
@@ -42,22 +32,14 @@ const unauthenticatedUser = {
};
describe('routes', () => {
let history;
beforeEach(() => {
history = createMemoryHistory();
});
test('Profile page should redirect for unauthenticated users', () => {
history.push('/u/edx');
render(
RoutesWithProvider(unauthenticatedUser, history),
RoutesWithProvider(unauthenticatedUser, '/u/edx'),
);
expect(getLoginRedirectUrl).toHaveBeenCalled();
});
test('Profile page should be accessible for authenticated users', () => {
history.push('/u/edx');
render(
RoutesWithProvider(
{
@@ -67,24 +49,31 @@ describe('routes', () => {
},
config: getConfig(),
},
history,
'/u/edx',
),
);
expect(screen.getByText('Profile page')).toBeTruthy();
});
test('Skills Builder page should be accessible to unauthenticated users', () => {
history.push('/skills');
test('Profile Plugin page should be accessible for authenticated users', () => {
render(
RoutesWithProvider(unauthenticatedUser, history),
RoutesWithProvider(
{
authenticatedUser: {
username: 'edx',
email: 'edx@example.com',
},
config: getConfig(),
},
'/u/edx/plugin',
),
);
expect(screen.getByText('Skills Builder')).toBeTruthy();
expect(screen.getByText('Plugin page')).toBeTruthy();
});
test('should show NotFound page for a bad route', () => {
history.push('/nonMatchingRoute');
render(
RoutesWithProvider(unauthenticatedUser, history),
RoutesWithProvider(unauthenticatedUser, '/nonMatchingRoute'),
);
expect(screen.getByText('Not found page')).toBeTruthy();
});

View File

@@ -2,6 +2,6 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
Enzyme.configure({ adapter: new Adapter() });

View File

@@ -1,11 +0,0 @@
import React from 'react';
import { SkillsBuilderModal } from './skills-builder-modal';
import { SkillsBuilderProvider } from './skills-builder-context';
const SkillsBuilder = () => (
<SkillsBuilderProvider>
<SkillsBuilderModal />
</SkillsBuilderProvider>
);
export default SkillsBuilder;

View File

@@ -1,26 +0,0 @@
import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREST,
} from './constants';
export const setGoal = (payload) => ({
type: SET_GOAL,
payload,
});
export const setCurrentJobTitle = (payload) => ({
type: SET_CURRENT_JOB_TITLE,
payload,
});
export const addCareerInterest = (payload) => ({
type: ADD_CAREER_INTEREST,
payload,
});
export const removeCareerInterest = (payload) => ({
type: REMOVE_CAREER_INTEREST,
payload,
});

View File

@@ -1,9 +0,0 @@
// Actions for Skills Context
export const SET_GOAL = 'SET_GOAL';
export const SET_CURRENT_JOB_TITLE = 'SET_CURRENT_JOB_TITLE';
export const ADD_CAREER_INTEREST = 'ADD_CAREER_INTEREST';
export const REMOVE_CAREER_INTEREST = 'REMOVE_CAREER_INTEREST';
// Stepper keys
export const STEP1 = 'select-your-preferences';
export const STEP2 = 'review-your-results';

View File

@@ -1,41 +0,0 @@
import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREST,
} from './constants';
export function skillsReducer(state, action) {
switch (action.type) {
case SET_GOAL:
return {
...state,
currentGoal: action.payload,
};
case SET_CURRENT_JOB_TITLE:
return {
...state,
currentJobTitle: action.payload,
};
case ADD_CAREER_INTEREST:
return {
...state,
careerInterests: [...state.careerInterests, action.payload],
};
case REMOVE_CAREER_INTEREST:
return {
...state,
careerInterests: state.careerInterests.filter(interest => interest !== action.payload),
};
default:
return state;
}
}
export const skillsInitialState = {
currentGoal: '',
currentJobTitle: '',
careerInterests: [],
};
export default skillsReducer;

View File

@@ -1,60 +0,0 @@
import { skillsReducer, skillsInitialState } from '../reducer';
import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREST,
} from '../constants';
describe('skillsReducer', () => {
const testState = skillsInitialState;
beforeEach(() => jest.resetModules());
it('does not remove present data when SET_GOAL action is dispatched', () => {
const newGoalPayload = 'test-goal';
const returnedState = skillsReducer(testState, { type: SET_GOAL, payload: newGoalPayload });
const finalState = {
...testState,
currentGoal: 'test-goal',
};
expect(returnedState).toEqual(finalState);
});
it('does not remove present data when SET_JOB_TITLE action is dispatched', () => {
const newJobTitlePayload = 'test-job-title';
const returnedState = skillsReducer(testState, { type: SET_CURRENT_JOB_TITLE, payload: newJobTitlePayload });
const finalState = {
...testState,
currentJobTitle: 'test-job-title',
};
expect(returnedState).toEqual(finalState);
});
it('adds a careerInterest when ADD_CAREER_INTEREST action is dispatched', () => {
const newCareerInterestPayload = 'test-career-interest';
const returnedState = skillsReducer(testState, { type: ADD_CAREER_INTEREST, payload: newCareerInterestPayload });
const finalState = {
...testState,
careerInterests: [...testState.careerInterests, 'test-career-interest'],
};
expect(returnedState).toEqual(finalState);
});
it('removes a careerInterest when REMOVE_CAREER_INTEREST action is dispatched', () => {
const newCareerInterestPayload = 'test-career-interest';
const testStateWithInterest = {
...testState,
careerInterests: [newCareerInterestPayload],
};
const returnedState = skillsReducer(
testStateWithInterest,
{ type: REMOVE_CAREER_INTEREST, payload: newCareerInterestPayload },
);
const finalState = {
...testStateWithInterest,
// override the 'careerInterests` field and remove 'test-career-interest' from the array
careerInterests: testStateWithInterest.careerInterests.filter(interest => interest !== newCareerInterestPayload),
};
expect(returnedState).toEqual(finalState);
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,3 +0,0 @@
<svg width="148" height="83" viewBox="0 0 148 83" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M97.6992 0L94.8413 14.525H148L133.537 83H66.3135L70.1777 64.325H0L13.7661 0H97.6992ZM45.3759 37.6596C45.419 37.1851 45.4513 36.7212 45.4729 36.2654C45.4944 35.8123 45.5052 35.4154 45.5025 35.0776C45.5025 32.8602 45.1523 30.8949 44.452 29.1842C43.7517 27.4736 42.8063 26.0311 41.6184 24.8594C40.4306 23.685 39.0461 22.7948 37.465 22.1808C35.8812 21.5668 34.2112 21.2611 32.4496 21.2611C29.8396 21.2611 27.4343 21.7679 25.2391 22.7814C23.0412 23.7949 21.1584 25.2267 19.588 27.0741C18.0177 28.9241 16.7922 31.1415 15.9114 33.7289C15.0306 36.3163 14.5915 39.1853 14.5915 42.333C14.5915 44.6362 14.9579 46.674 15.6905 48.449C16.4231 50.2239 17.4198 51.7174 18.683 52.932C19.9463 54.1466 21.4277 55.0663 23.1246 55.6883C24.8216 56.313 26.6262 56.6241 28.5359 56.6241C30.5319 56.6241 32.3608 56.3854 34.0281 55.9109C35.6927 55.4363 37.1579 54.7794 38.4212 53.9455C39.6845 53.1116 40.7296 52.1223 41.5565 50.9827C42.3834 49.8432 42.9787 48.6179 43.3396 47.3067H37.5135C36.7916 48.6393 35.7411 49.6957 34.3621 50.476C32.983 51.2562 31.1568 51.6477 28.8861 51.6477C27.8464 51.6477 26.8175 51.4734 25.7993 51.1248C24.7812 50.7763 23.8627 50.2132 23.0465 49.4303C22.2277 48.6474 21.5651 47.6392 21.056 46.4032C20.5469 45.1671 20.2911 43.6737 20.2911 41.9201C20.2911 41.6252 20.2964 41.341 20.3072 41.0648C20.318 40.7913 20.3342 40.5071 20.3557 40.2095H45.0904C45.155 39.8931 45.2062 39.507 45.2493 39.0539C45.2762 38.7515 45.3044 38.4432 45.333 38.1306L45.3759 37.6596ZM37.8017 28.5488C37.1445 27.7873 36.3256 27.1867 35.3506 26.7416C34.3728 26.2992 33.2388 26.0767 31.9433 26.0767C30.6261 26.0767 29.3952 26.3099 28.2504 26.7738C27.103 27.2376 26.0633 27.8999 25.1313 28.7552C24.1967 29.6105 23.3913 30.6348 22.7125 31.8279C22.0338 33.0211 21.4924 34.3483 21.0883 35.8042H39.7114C39.7329 35.6997 39.7491 35.5039 39.7599 35.217C39.7706 34.9328 39.776 34.6513 39.776 34.3778C39.776 33.257 39.6117 32.1953 39.2831 31.1925C38.9518 30.1924 38.4589 29.3102 37.8017 28.5488ZM71.4707 56.0235L72.4565 51.3661H71.9475C70.695 52.9937 69.1246 54.2753 67.2365 55.2164C65.3483 56.1549 63.3093 56.6268 61.1249 56.6268C59.4064 56.6268 57.793 56.305 56.2846 55.6588C54.7762 55.0153 53.4564 54.0903 52.3224 52.8864C51.1857 51.6825 50.2888 50.2239 49.6316 48.5133C48.9743 46.8027 48.6457 44.8909 48.6457 42.7781C48.6457 40.7297 48.8531 38.7697 49.2652 36.9008C49.68 35.032 50.2672 33.2999 51.0322 31.7046C51.7972 30.1092 52.713 28.6667 53.785 27.3797C54.857 26.0901 56.0449 24.9934 57.3512 24.0845C58.6576 23.1755 60.0663 22.4784 61.5854 21.9931C63.1019 21.5078 64.6884 21.2638 66.3449 21.2638C67.5759 21.2638 68.7475 21.4327 69.8627 21.7706C70.9778 22.1084 71.9852 22.5776 72.8875 23.1809C73.7898 23.7842 74.5629 24.5108 75.212 25.3661C75.8585 26.2214 76.3325 27.1572 76.6288 28.1707H77.1379L81.3721 8.3H86.9423L76.7554 56.0261H71.4707V56.0235ZM70.3556 26.8381C71.4169 27.2805 72.3192 27.9106 73.0626 28.723C73.806 29.5381 74.3771 30.5248 74.7784 31.6831C75.1824 32.8468 75.3844 34.1552 75.3844 35.6138C75.3844 37.8741 75.0639 39.9816 74.4282 41.9362C73.7926 43.8908 72.9118 45.5907 71.7859 47.0386C70.6627 48.4838 69.3402 49.626 67.8237 50.4599C66.3046 51.2964 64.6561 51.712 62.873 51.712C61.6016 51.712 60.4434 51.4734 59.4037 50.9988C58.364 50.5216 57.4725 49.8566 56.7291 49.0013C55.9856 48.146 55.4092 47.1164 54.9944 45.9125C54.5796 44.7086 54.3722 43.3894 54.3722 41.9523C54.3722 39.6491 54.6927 37.5309 55.3284 35.5978C55.9641 33.6673 56.8341 32.0022 57.9384 30.608C59.0428 29.2137 60.3465 28.1278 61.8548 27.3449C63.3605 26.5646 64.9955 26.1732 66.7571 26.1732C68.0957 26.1732 69.2944 26.393 70.3556 26.8381ZM126.409 74.7H114.031L105.938 57.0191H104.927L89.9496 74.7H77.6479L100.989 47.1736L90.1767 22.825H102.789L109.982 39.5884H110.652L124.254 22.825H136.724L114.442 48.3925L126.409 74.7Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as SkillsBuilder } from './SkillsBuilder';

View File

@@ -1,32 +0,0 @@
import React, { createContext, useReducer, useMemo } from 'react';
import PropTypes from 'prop-types';
import reducer, { skillsInitialState } from '../data/reducer';
import { useAlgoliaSearch } from '../utils/search';
export const SkillsBuilderContext = createContext();
export const SkillsBuilderProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, skillsInitialState);
const [searchClient, productSearchIndex, jobSearchIndex] = useAlgoliaSearch();
const value = useMemo(() => ({
state,
dispatch,
algolia: {
searchClient,
productSearchIndex,
jobSearchIndex,
},
}), [state, searchClient, productSearchIndex, jobSearchIndex]);
return (
<SkillsBuilderContext.Provider value={value}>
{children}
</SkillsBuilderContext.Provider>
);
};
SkillsBuilderProvider.propTypes = {
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.node]).isRequired,
};

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { SkillsBuilderProvider, SkillsBuilderContext } from './SkillsBuilderProvider';

View File

@@ -1,25 +0,0 @@
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import edXLogo from '../images/edX-logo.svg';
import messages from './messages';
const SkillsBuilderHeader = () => {
const { formatMessage } = useIntl();
return (
<div className="d-flex">
<img src={edXLogo} alt="edx-logo" className="mt-2 h-50" />
<div className="ml-5 vertical-line" />
<div className="w-100 ml-5">
<h1 className="h1 text-warning-300">
{formatMessage(messages.skillsBuilderHeaderTitle)}
</h1>
<p className="h2 text-white">
{formatMessage(messages.skillsBuilderHeaderSubheading)}
</p>
</div>
</div>
);
};
export default SkillsBuilderHeader;

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as SkillsBuilderHeader } from './SkillsBuilderHeader';

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
skillsBuilderHeaderTitle: {
id: 'skills.builder.header.title',
defaultMessage: 'Skills Builder',
description: 'Title for the Skills Builder feature',
},
skillsBuilderHeaderSubheading: {
id: 'skills.builder.header.subheading',
defaultMessage: 'Let edX be your guide',
description: 'Subheading to the Skills Builder title in the header component',
},
});
export default messages;

View File

@@ -1,4 +0,0 @@
.vertical-line {
border-left: 7px solid #D23228;
transform: rotate(13deg);
}

View File

@@ -1,113 +0,0 @@
import React, { useState, useContext } from 'react';
import {
Button, Container, Stepper, ModalDialog, Form, Hyperlink,
} from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
STEP1, STEP2,
} from '../data/constants';
import messages from './messages';
import { SkillsBuilderContext } from '../skills-builder-context';
import { SkillsBuilderHeader } from '../skills-builder-header';
import { SelectPreferences } from './select-preferences';
import ViewResults from './view-results/ViewResults';
import headerImage from '../images/headerImage.png';
const SkillsBuilderModal = () => {
const { formatMessage } = useIntl();
const { state } = useContext(SkillsBuilderContext);
const { currentGoal, currentJobTitle, careerInterests } = state;
const [currentStep, setCurrentStep] = useState(STEP1);
const sendActionButtonEvent = (eventSuffix) => {
sendTrackEvent(
`edx.skills_builder.${eventSuffix}`,
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
current_goal: currentGoal,
current_job_title: currentJobTitle,
career_interests: careerInterests,
},
},
);
};
const nextStepHandle = () => {
setCurrentStep(STEP2);
sendActionButtonEvent('next_step');
};
const exitButtonHandle = () => {
sendActionButtonEvent('exit');
};
const closeButtonHandle = () => {
sendActionButtonEvent('close');
window.location.href = getConfig().MARKETING_SITE_SEARCH_URL;
};
return (
<Stepper activeKey={currentStep}>
<ModalDialog
title="Skills Builder"
size="fullscreen"
className="skills-builder-modal bg-light-200"
isOpen
onClose={closeButtonHandle}
>
<ModalDialog.Hero>
<ModalDialog.Hero.Background className="bg-primary-500">
<img src={headerImage} alt="" className="h-100" />
</ModalDialog.Hero.Background>
<ModalDialog.Hero.Content>
<SkillsBuilderHeader />
</ModalDialog.Hero.Content>
</ModalDialog.Hero>
<Stepper.Header />
<ModalDialog.Body>
<Container size="md" className="p-4.5">
<Form>
<Stepper.Step eventKey={STEP1} title={formatMessage(messages.selectPreferences)}>
<SelectPreferences />
</Stepper.Step>
<Stepper.Step eventKey={STEP2} title={formatMessage(messages.reviewResults)}>
<ViewResults />
</Stepper.Step>
</Form>
</Container>
</ModalDialog.Body>
<ModalDialog.Footer>
<Stepper.ActionRow eventKey={STEP1}>
<Button
onClick={nextStepHandle}
disabled={careerInterests.length === 0}
>
{formatMessage(messages.nextStepButton)}
</Button>
</Stepper.ActionRow>
<Stepper.ActionRow eventKey={STEP2}>
<Button variant="outline-primary" onClick={() => setCurrentStep(STEP1)}>
{formatMessage(messages.goBackButton)}
</Button>
<Stepper.ActionRow.Spacer />
<Hyperlink destination={getConfig().MARKETING_SITE_SEARCH_URL}>
<Button onClick={exitButtonHandle}>
{formatMessage(messages.exitButton)}
</Button>
</Hyperlink>
</Stepper.ActionRow>
</ModalDialog.Footer>
</ModalDialog>
</Stepper>
);
};
export default SkillsBuilderModal;

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as SkillsBuilderModal } from './SkillsBuilderModal';

View File

@@ -1,32 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
/* Modal Action Row Buttons */
goBackButton: {
id: 'go.back.button',
defaultMessage: 'Go Back',
description: 'Button that sends the user to the previous step in the skills builder.',
},
nextStepButton: {
id: 'next.step.button',
defaultMessage: 'Next Step',
description: 'Button that sends the user to the next step in the skills builder.',
},
exitButton: {
id: 'exit.button',
defaultMessage: 'Exit',
description: 'Button that exits the Skills Builder.',
},
selectPreferences: {
id: 'select.preferences',
defaultMessage: 'Select preferences',
description: 'The first step of the Skills Builder for selecting a goal, a current job/occupation, and career interests',
},
reviewResults: {
id: 'review.results',
defaultMessage: 'Review results',
description: 'The second step of the Skills Builder for rendering results from learner input',
},
});
export default messages;

View File

@@ -1,51 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import {
IconButton, Icon,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { Close } from '@edx/paragon/icons';
import { SkillsBuilderContext } from '../../skills-builder-context';
import { removeCareerInterest } from '../../data/actions';
import messages from './messages';
const CareerInterestCard = ({ interest }) => {
const { formatMessage } = useIntl();
const { dispatch } = useContext(SkillsBuilderContext);
const handleRemoveCareerInterest = () => {
dispatch(removeCareerInterest(interest));
sendTrackEvent(
'edx.skills_builder.career_interest.removed',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
career_interest: interest,
},
},
);
};
return (
<div className="d-flex justify-content-between align-items-center pb-2 pr-2 pl-4 rounded shadow-sm">
<p className="pt-4">
{interest}
</p>
<IconButton
iconAs={Icon}
src={Close}
alt={`${formatMessage(messages.removeCareerInterestButtonAltText)} ${interest}`}
onClick={handleRemoveCareerInterest}
/>
</div>
);
};
CareerInterestCard.propTypes = {
interest: PropTypes.string.isRequired,
};
export default CareerInterestCard;

View File

@@ -1,65 +0,0 @@
import React, { useContext } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
Stack, Row, Col, Form,
} from '@edx/paragon';
import { Configure, InstantSearch } from 'react-instantsearch-hooks-web';
import JobTitleInstantSearch from './JobTitleInstantSearch';
import CareerInterestCard from './CareerInterestCard';
import { addCareerInterest } from '../../data/actions';
import { SkillsBuilderContext } from '../../skills-builder-context';
import messages from './messages';
const CareerInterestSelect = () => {
const { formatMessage } = useIntl();
const { state, dispatch, algolia } = useContext(SkillsBuilderContext);
const { careerInterests } = state;
const { searchClient } = algolia;
const handleCareerInterestSelect = (value) => {
if (!careerInterests.includes(value) && careerInterests.length < 3) {
dispatch(addCareerInterest(value));
sendTrackEvent(
'edx.skills_builder.career_interest.added',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
career_interest: value,
},
},
);
}
};
return (
<Stack gap={2}>
<Form.Label>
<h4 className="mb-3">
{formatMessage(messages.careerInterestPrompt)}
</h4>
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
<Configure filters="b2c_opt_in:true" />
<JobTitleInstantSearch
onSelected={handleCareerInterestSelect}
placeholder={formatMessage(messages.careerInterestInputPlaceholderText)}
data-testid="career-interest-select"
/>
</InstantSearch>
</Form.Label>
<Row>
{careerInterests.map((interest, index) => (
// eslint-disable-next-line react/no-array-index-key
<Col key={index} xs={12} sm={4} className="mb-4">
<CareerInterestCard interest={interest} />
</Col>
))}
</Row>
</Stack>
);
};
export default CareerInterestSelect;

View File

@@ -1,56 +0,0 @@
import React, { useContext } from 'react';
import {
Form,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { setGoal } from '../../data/actions';
import { SkillsBuilderContext } from '../../skills-builder-context';
import messages from './messages';
const GoalDropdown = () => {
const { formatMessage } = useIntl();
const { state, dispatch } = useContext(SkillsBuilderContext);
const { currentGoal } = state;
const handleGoalSelect = (e) => {
const { value } = e.target;
dispatch(setGoal(value));
sendTrackEvent(
'edx.skills_builder.goal.select',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
current_goal: value,
},
},
);
};
return (
<Form.Group>
<Form.Label>
<h4>
{formatMessage(messages.learningGoalPrompt)}
</h4>
</Form.Label>
<Form.Control
as="select"
value={currentGoal}
onChange={handleGoalSelect}
data-testid="goal-select-dropdown"
>
<option value="" disabled={currentGoal}>{formatMessage(messages.selectLearningGoal)}</option>
<option>{formatMessage(messages.learningGoalStartCareer)}</option>
<option>{formatMessage(messages.learningGoalAdvanceCareer)}</option>
<option>{formatMessage(messages.learningGoalChangeCareer)}</option>
<option>{formatMessage(messages.learningGoalSomethingNew)}</option>
<option>{formatMessage(messages.learningGoalSomethingElse)}</option>
</Form.Control>
</Form.Group>
);
};
export default GoalDropdown;

View File

@@ -1,43 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Form,
} from '@edx/paragon';
import { useHits, useSearchBox } from 'react-instantsearch-hooks-web';
const JobTitleInstantSearch = (props) => {
const { refine } = useSearchBox(props);
const { hits } = useHits(props);
const [jobInput, setJobInput] = useState('');
const handleAutosuggestChange = (value) => {
setJobInput(value);
};
useEffect(() => {
refine(jobInput);
}, [jobInput, refine]);
return (
<Form.Autosuggest
value={jobInput}
onChange={handleAutosuggestChange}
name="job-title-suggest"
autoComplete="off"
{...props}
>
{hits.map(job => (
<Form.AutosuggestOption key={job.id} id={job.name.replaceAll(' ', '-').toLowerCase()}>
{job.name}
</Form.AutosuggestOption>
))}
</Form.Autosuggest>
);
};
JobTitleInstantSearch.propTypes = {
onSelected: PropTypes.func.isRequired,
};
export default JobTitleInstantSearch;

View File

@@ -1,79 +0,0 @@
import React, { useContext } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
Form, Stack,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { InstantSearch } from 'react-instantsearch-hooks-web';
import { setCurrentJobTitle } from '../../data/actions';
import { SkillsBuilderContext } from '../../skills-builder-context';
import JobTitleInstantSearch from './JobTitleInstantSearch';
import messages from './messages';
const JobTitleSelect = () => {
const { formatMessage } = useIntl();
const { state, dispatch, algolia } = useContext(SkillsBuilderContext);
const { searchClient } = algolia;
const { currentJobTitle } = state;
const handleCurrentJobTitleSelect = (value) => {
dispatch(setCurrentJobTitle(value));
sendTrackEvent(
'edx.skills_builder.current_job.select',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
current_job_title: value,
},
},
);
};
const handleCheckboxChange = (e) => {
const { value } = e.target;
// only setCurrentJobTitle if the user hasn't selected a current job as we don't want to override their selection
if (!currentJobTitle) { dispatch(setCurrentJobTitle(value)); }
sendTrackEvent(
`edx.skills_builder.current_job.${value}`,
{
app_name: 'skills_builder',
category: 'skills_builder',
},
);
};
return (
<Stack>
<Form.Label>
<h4 className="mb-3">
{formatMessage(messages.jobTitlePrompt)}
</h4>
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
<JobTitleInstantSearch
onSelected={handleCurrentJobTitleSelect}
placeholder={formatMessage(messages.jobTitleInputPlaceholderText)}
data-testid="job-title-select"
/>
</InstantSearch>
</Form.Label>
<Form.Group>
<Form.CheckboxSet
name="other-occupations"
onChange={handleCheckboxChange}
>
<Form.Checkbox value="student">
{formatMessage(messages.studentCheckboxPrompt)}
</Form.Checkbox>
<Form.Checkbox value="looking_for_work">
{formatMessage(messages.currentlyLookingCheckboxPrompt)}
</Form.Checkbox>
</Form.CheckboxSet>
</Form.Group>
</Stack>
);
};
export default JobTitleSelect;

View File

@@ -1,38 +0,0 @@
import React, { useContext } from 'react';
import {
Stack,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { SkillsBuilderContext } from '../../skills-builder-context';
import GoalSelect from './GoalSelect';
import JobTitleSelect from './JobTitleSelect';
import CareerInterestSelect from './CareerInterestSelect';
import messages from './messages';
const SelectPreferences = () => {
const { formatMessage } = useIntl();
const { state } = useContext(SkillsBuilderContext);
const { currentGoal, currentJobTitle } = state;
return (
<Stack gap={4}>
<p className="lead">
{formatMessage(messages.skillsBuilderDescription)}
</p>
<Stack gap={4}>
<GoalSelect />
{currentGoal && (
<JobTitleSelect />
)}
{currentGoal && currentJobTitle && (
<CareerInterestSelect />
)}
</Stack>
</Stack>
);
};
export default SelectPreferences;

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as SelectPreferences } from './SelectPreferences';

View File

@@ -1,80 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
skillsBuilderDescription: {
id: 'skills.builder.description',
defaultMessage: 'Find the right courses and programs that help you reach your goals.',
description: 'Description of what the Skills Builder seeks to accomplish',
},
learningGoalPrompt: {
id: 'learning.goal.prompt',
defaultMessage: 'First, tell us what you want to achieve',
description: 'Prompts the user to select their current goal.',
},
selectLearningGoal: {
id: 'select.learning.goal',
defaultMessage: 'Select a goal',
description: 'Placeholder text for the goal selection component.',
},
learningGoalStartCareer: {
id: 'learning.goal.start_career',
defaultMessage: 'I want to start my career',
description: 'Selected by user if their goal is to start their career.',
},
learningGoalAdvanceCareer: {
id: 'learning.goal.advance_career',
defaultMessage: 'I want to advance my career',
description: 'Selected by user if their goal is to advance their career.',
},
learningGoalChangeCareer: {
id: 'learning.goal.change_career',
defaultMessage: 'I want to change careers',
description: 'Selected by user if their goal is to change careers.',
},
learningGoalSomethingNew: {
id: 'learning.goal.something.new',
defaultMessage: 'I want to learn something new',
description: 'Selected by user if their goal is to learn something new.',
},
learningGoalSomethingElse: {
id: 'learning.goal.something.else',
defaultMessage: 'Something else',
description: 'Selected by user if their goal is not described by the other choices.',
},
jobTitlePrompt: {
id: 'job.title.prompt',
defaultMessage: 'Next, search and select your current job title',
description: 'Prompts the user to select their current job title or occupation.',
},
jobTitleInputPlaceholderText: {
id: 'job.title.input.placeholder.text',
defaultMessage: 'Search and select a job title',
description: 'Placeholder text for the job title input control.',
},
studentCheckboxPrompt: {
id: 'student.checkbox.prompt',
defaultMessage: 'I\'m a student',
description: 'Label text for the corresponding checkbox',
},
currentlyLookingCheckboxPrompt: {
id: 'currently.looking.checkbox.prompt',
defaultMessage: 'I\'m currently looking for work',
description: 'Label text for the corresponding checkbox',
},
careerInterestPrompt: {
id: 'career.interest.prompt',
defaultMessage: 'What careers are you interested in?',
description: 'Prompts the user to select careers they are interested in pursuing.',
},
careerInterestInputPlaceholderText: {
id: 'career.interest.input.placeholder.text',
defaultMessage: 'Select up to 3 new job titles',
description: 'Placeholder text for the career interest input control.',
},
removeCareerInterestButtonAltText: {
id: 'career.interest.remove.button.alt.text',
defaultMessage: 'Remove career interest: ',
},
});
export default messages;

View File

@@ -1,180 +0,0 @@
import {
screen, render, cleanup, fireEvent,
} from '@testing-library/react';
import { mergeConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { SkillsBuilderWrapperWithContext, dispatchMock, contextValue } from '../../../test/setupSkillsBuilder';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
describe('select-preferences', () => {
beforeAll(() => {
mergeConfig({
ALGOLIA_JOBS_INDEX_NAME: 'test-job-index-name',
});
});
beforeEach(() => cleanup());
describe('render behavior', () => {
it('should render the second prompt if a goal is selected', () => {
render(
SkillsBuilderWrapperWithContext(
{
...contextValue,
state: {
...contextValue.state,
currentGoal: 'I want to start my career',
},
},
),
);
const expectedGoal = {
payload: 'I want to advance my career',
type: 'SET_GOAL',
};
const expectedStudent = {
payload: 'student',
type: 'SET_CURRENT_JOB_TITLE',
};
const expectedJobTitle = {
payload: 'Prospector',
type: 'SET_CURRENT_JOB_TITLE',
};
const goalSelect = screen.getByTestId('goal-select-dropdown');
fireEvent.change(goalSelect, { target: { value: 'I want to advance my career' } });
const checkbox = screen.getByRole('checkbox', { name: 'I\'m a student' });
fireEvent.click(checkbox);
const jobTitleInput = screen.getByTestId('job-title-select');
fireEvent.change(jobTitleInput, { target: { value: 'Prospector' } });
fireEvent.click(screen.getByRole('button', { name: 'Prospector' }));
expect(screen.getByText('Next, search and select your current job title')).toBeTruthy();
expect(dispatchMock).toHaveBeenCalledWith(expectedGoal);
expect(dispatchMock).toHaveBeenCalledWith(expectedStudent);
expect(dispatchMock).toHaveBeenCalledWith(expectedJobTitle);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.goal.select',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
current_goal: 'I want to advance my career',
},
},
);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.current_job.student',
{
app_name: 'skills_builder',
category: 'skills_builder',
},
);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.current_job.select',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
current_job_title: 'Prospector',
},
},
);
});
it('should render the third prompt if a current job title is selected', () => {
render(
SkillsBuilderWrapperWithContext(
{
...contextValue,
state: {
...contextValue.state,
currentGoal: 'I want to start my career',
currentJobTitle: 'Goblin Guide',
},
},
),
);
expect(screen.getByText('What careers are you interested in?')).toBeTruthy();
});
it('should render a <CareerInterestCard> for each career interest', () => {
render(
SkillsBuilderWrapperWithContext(
{
...contextValue,
state: {
...contextValue.state,
currentGoal: 'I want to start my career',
currentJobTitle: 'Goblin Lackey',
careerInterests: ['Prospector'],
},
},
),
);
expect(screen.getByText('Prospector')).toBeTruthy();
const careerInterestInput = screen.getByTestId('career-interest-select');
fireEvent.change(careerInterestInput, { target: { value: 'Mirror Breaker' } });
fireEvent.click(screen.getByRole('button', { name: 'Mirror Breaker' }));
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.career_interest.added',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
career_interest: 'Mirror Breaker',
},
},
);
expect(dispatchMock).toHaveBeenCalledWith(
{
payload: 'Mirror Breaker',
type: 'ADD_CAREER_INTEREST',
},
);
});
});
describe('controlled behavior', () => {
it('should remove a <CareerInterestCard> when the corresponding close button is selected', () => {
render(
SkillsBuilderWrapperWithContext(
{
...contextValue,
state: {
...contextValue.state,
currentGoal: 'I want to start my career',
currentJobTitle: 'Goblin Lackey',
careerInterests: ['Prospector', 'Mirror Breaker', 'Bombardment'],
},
},
),
);
const expected = {
payload: 'Prospector',
type: 'REMOVE_CAREER_INTEREST',
};
fireEvent.click(screen.getByLabelText('Remove career interest: Prospector'));
expect(dispatchMock).toHaveBeenCalledWith(expected);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.career_interest.removed',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
career_interest: 'Prospector',
},
},
);
});
});
});

View File

@@ -1,5 +0,0 @@
.skills-builder-modal {
button[aria-label="Close"][type="button"]{
color: #ffffff;
}
}

View File

@@ -1,80 +0,0 @@
import React from 'react';
import { CardCarousel } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import RecommendationCard from './RecommendationCard';
import messages from './messages';
const CarouselStack = ({ selectedRecommendations }) => {
const { formatMessage } = useIntl();
const { id: jobId, name: jobName, recommendations } = selectedRecommendations;
const productTypeNames = Object.keys(recommendations);
const courseKeys = recommendations.course?.map(rec => ({
title: rec.title,
courserun_key: rec.active_run_key,
}));
const normalizeProductTypeName = (productType) => {
// If the productType is more than one word (i.e. boot_camp)
if (productType.includes('_')) {
// split to remove underscore and return an array of strings (i.e. ['boot', 'camp'])
const splitStrings = productType.split('_');
// map through the array and normalize each string (i.e. ['Boot', 'Camp'])
const normalizeStrings = splitStrings.map(word => word[0].toUpperCase() + word.slice(1).toLowerCase());
// return the array as a string joined by white spaces (i.e. Boot Camp)
return normalizeStrings.join(' ');
}
// Otherwise, return a normalized string
const normalizeString = productType[0].toUpperCase() + productType.slice(1).toLowerCase();
return normalizeString;
};
const renderCarouselTitle = (productType) => (
<h3>
{formatMessage(messages.productRecommendationsHeaderText, {
productType: normalizeProductTypeName(productType),
jobName,
})}
</h3>
);
const handleCourseCardClick = (courseKey, productType) => {
sendTrackEvent(
'edx.skills_builder.recommendation.click',
{
app_name: 'skills_builder',
category: 'skills_builder',
page: 'skills_builder',
courserun_key: courseKey,
product_type: productType,
selected_recommendations: {
job_id: jobId,
job_name: jobName,
courserun_keys: courseKeys,
},
},
);
};
return (
productTypeNames.map(productType => (
<CardCarousel
key={productType}
ariaLabel="card carousel"
title={renderCarouselTitle(productType)}
>
{recommendations[productType].map(rec => (
<RecommendationCard
key={rec.uuid}
handleCourseCardClick={handleCourseCardClick}
rec={rec}
productType={productType}
/>
))}
</CardCarousel>
)));
};
export default CarouselStack;

View File

@@ -1,60 +0,0 @@
import React from 'react';
import { Card, Chip, Hyperlink } from '@edx/paragon';
import PropTypes from 'prop-types';
import cardImageCapFallbackSrc from '../../images/card-imagecap-fallback.png';
const RecommendationCard = ({ rec, productType, handleCourseCardClick }) => {
const {
card_image_url: cardImageUrl,
marketing_url: marketingUrl,
active_run_key: courseKey,
owners,
partner,
title,
} = rec;
const { logoImageUrl } = owners[0];
return (
<Hyperlink destination={marketingUrl} target="_blank" showLaunchIcon={false}>
<Card
className="carousel-card"
onClick={() => handleCourseCardClick(courseKey, productType)}
>
<Card.ImageCap
src={cardImageUrl}
logoSrc={logoImageUrl}
fallbackSrc={cardImageCapFallbackSrc}
fallbackLogoSrc={cardImageCapFallbackSrc}
/>
<Card.Header title={title} />
<Card.Section>
{partner.map((orgName, index) => (
// eslint-disable-next-line react/no-array-index-key
<Chip key={index}>
{orgName}
</Chip>
))}
</Card.Section>
</Card>
</Hyperlink>
);
};
RecommendationCard.propTypes = {
rec: PropTypes.shape({
title: PropTypes.string,
card_image_url: PropTypes.string,
marketing_url: PropTypes.string,
partner: PropTypes.arrayOf(PropTypes.string),
owners: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string,
logoImageUrl: PropTypes.string,
})),
active_run_key: PropTypes.string.isRequired,
}).isRequired,
productType: PropTypes.string.isRequired,
handleCourseCardClick: PropTypes.func.isRequired,
};
export default RecommendationCard;

View File

@@ -1,57 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
SelectableBox, Chip, Stack, useMediaQuery, breakpoints,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
const RelatedSkillsSelectableBoxSet = ({ jobSkillsList, selectedJobTitle, onChange }) => {
const { formatMessage } = useIntl();
const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth });
const renderTopFiveSkills = (skills) => {
const topFiveSkills = skills.sort((a, b) => b.significance - a.significance).slice(0, 5);
return (
topFiveSkills.map(skill => (
<Chip key={skill.external_id}>
{skill.name}
</Chip>
))
);
};
return (
<SelectableBox.Set
name="selected job title"
type="radio"
value={selectedJobTitle}
onChange={onChange}
columns={isExtraSmall ? 1 : 3}
>
{jobSkillsList.map(job => (
<SelectableBox
key={job.id}
type="radio"
value={job.name}
aria-label={job.name}
inputHidden={false}
>
<p>{job.name}</p>
<Stack gap={2} className="align-items-start">
<p className="heading-label x-small">{formatMessage(messages.relatedSkillsHeading)}</p>
{renderTopFiveSkills(job.skills)}
</Stack>
</SelectableBox>
))}
</SelectableBox.Set>
);
};
RelatedSkillsSelectableBoxSet.propTypes = {
jobSkillsList: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
selectedJobTitle: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default RelatedSkillsSelectableBoxSet;

View File

@@ -1,165 +0,0 @@
import React, {
useContext, useEffect, useState,
} from 'react';
import {
Stack, Row, Alert, Spinner,
} from '@edx/paragon';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import { CheckCircle, ErrorOutline } from '@edx/paragon/icons';
import { SkillsBuilderContext } from '../../skills-builder-context';
import RelatedSkillsSelectableBoxSet from './RelatedSkillsSelectableBoxSet';
import { searchJobs, getProductRecommendations } from '../../utils/search';
import messages from './messages';
import { productTypes } from './data/constants';
import CarouselStack from './CarouselStack';
const ViewResults = () => {
const { formatMessage } = useIntl();
const { algolia, state } = useContext(SkillsBuilderContext);
const { jobSearchIndex, productSearchIndex } = algolia;
const { careerInterests } = state;
const [selectedJobTitle, setSelectedJobTitle] = useState('');
const [jobSkillsList, setJobSkillsList] = useState([]);
const [productRecommendations, setProductRecommendations] = useState([]);
const [selectedRecommendations, setSelectedRecommendations] = useState({});
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
useEffect(() => {
const getRecommendations = async () => {
// fetch list of jobs with related skills
const jobInfo = await searchJobs(jobSearchIndex, careerInterests);
// fetch course recommendations based on related skills for each job
const results = await Promise.all(jobInfo.map(async (job) => {
const formattedSkills = job.skills.map(skill => skill.name);
// create a data object for each job
const data = {
id: job.id,
name: job.name,
recommendations: {},
};
// get recommendations for each product type based on the skills for the current job
await Promise.all(productTypes.map(async (productType) => {
const response = await getProductRecommendations(productSearchIndex, productType, formattedSkills);
// replace all white spaces with an underscore
const formattedProductType = productType.replace(' ', '_');
// add a new key to the recommendations object and set the value to the response
data.recommendations[formattedProductType] = response;
}));
return data;
}));
setJobSkillsList(jobInfo);
setSelectedJobTitle(results[0].name);
setProductRecommendations(results);
setIsLoading(false);
sendTrackEvent('edx.skills_builder.recommendation.shown', {
app_name: 'skills_builder',
category: 'skills_builder',
page: 'skills_builder',
selected_recommendations: {
job_id: results[0].id,
job_name: results[0].name,
/* We extract the title and course key into an array of objects */
courserun_keys: results[0].recommendations.course?.map(rec => ({
title: rec.title,
courserun_key: rec.active_run_key,
})),
},
is_default: true,
});
};
getRecommendations()
.catch(() => {
setFetchError(true);
setIsLoading(false);
});
}, [careerInterests, jobSearchIndex, productSearchIndex]);
useEffect(() => {
setSelectedRecommendations(productRecommendations.find(rec => rec.name === selectedJobTitle));
}, [productRecommendations, selectedJobTitle]);
const handleJobTitleChange = (e) => {
const { value } = e.target;
setSelectedJobTitle(value);
const currentSelection = productRecommendations.find(rec => rec.name === value);
const { id: jobId, name: jobName, recommendations } = currentSelection;
const courseKeys = recommendations.course?.map(rec => ({
title: rec.title,
courserun_key: rec.active_run_key,
}));
/*
The is_default value will be set to false for any selections made by the user.
This code is intentionally duplicated from the event that fires in the useEffect for fetching recommendations.
This proved less clunky than refactoring to make things DRY as we have to ensure the first call fires only once.
The previous implementation wrapped the event in an additional useEffect that was looping unnecessarily.
We have plans to refactor all of the event code as part of APER-2392, where we will revisit this approach.
*/
sendTrackEvent('edx.skills_builder.recommendation.shown', {
app_name: 'skills_builder',
category: 'skills_builder',
page: 'skills_builder',
selected_recommendations: {
job_id: jobId,
job_name: jobName,
courserun_keys: courseKeys,
},
is_default: false,
});
};
if (fetchError) {
return (
<Alert
variant="danger"
icon={ErrorOutline}
>
<Alert.Heading>
{formatMessage(messages.matchesNotFoundDangerAlert)}
</Alert.Heading>
</Alert>
);
}
return (
isLoading ? (
<Row>
<Spinner
animation="border"
screenReaderText="loading"
className="mx-auto"
/>
</Row>
) : (
<Stack gap={4.5} className="pb-4.5">
<Alert
variant="success"
icon={CheckCircle}
>
<Alert.Heading>
{formatMessage(messages.matchesFoundSuccessAlert)}
</Alert.Heading>
</Alert>
<RelatedSkillsSelectableBoxSet
jobSkillsList={jobSkillsList}
selectedJobTitle={selectedJobTitle}
onChange={handleJobTitleChange}
/>
<CarouselStack selectedRecommendations={selectedRecommendations} />
</Stack>
)
);
};
export default ViewResults;

View File

@@ -1,11 +0,0 @@
const COURSE = 'course';
/* The below strings can be used to demonstrate how we are able to retrieve recommendations for other product types
const BOOT_CAMP = 'boot camp';
const EXECUTIVE_EDUCATION = 'executive education';
*/
// eslint-disable-next-line import/prefer-default-export
export const productTypes = [
COURSE,
];

View File

@@ -1,2 +0,0 @@
// eslint-disable-next-line import/prefer-default-export
export { default as ViewResults } from './ViewResults';

View File

@@ -1,31 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
matchesFoundSuccessAlert: {
id: 'matches.found.success.alert',
defaultMessage: 'We found skills and courses that match your preferences!',
description: 'Success alert message to display when recommendations are presented to the learner.',
},
matchesNotFoundDangerAlert: {
id: 'matches.not.found.danger.alert',
defaultMessage: 'We were not able to retrieve recommendations at this time. Please try again later.',
description: 'Danger alert message to display when the component fails to get recommendations.',
},
relatedSkillsHeading: {
id: 'related.skills.heading',
defaultMessage: 'Related Skills',
description: 'Heading text for a selectable box that displays related skills for a corresponding selected job title.',
},
relatedSkillsSelectableBoxLabelText: {
id: 'related.skills.selectable.box.label.text',
defaultMessage: 'Related skills:',
description: 'Label text for a selectable box that displays related skills for a corresponding selected job title.',
},
productRecommendationsHeaderText: {
id: 'product.recommendations.header.text',
defaultMessage: '{productType} recommendations for {jobName}',
description: 'Header text for a carousel of product recommendations.',
},
});
export default messages;

View File

@@ -1,179 +0,0 @@
import {
screen, render, cleanup, fireEvent, act,
} from '@testing-library/react';
import { mergeConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { SkillsBuilderWrapperWithContext, contextValue } from '../../../test/setupSkillsBuilder';
import { getProductRecommendations } from '../../../utils/search';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
const renderSkillsBuilderWrapper = (
value = {
...contextValue,
state: {
...contextValue.state,
currentGoal: 'I want to start my career',
currentJobTitle: 'Goblin Lackey',
careerInterests: ['Prospector', 'Mirror Breaker', 'Bombardment'],
},
},
) => {
render(SkillsBuilderWrapperWithContext(value));
};
describe('view-results', () => {
beforeAll(() => {
mergeConfig({
ALGOLIA_JOBS_INDEX_NAME: 'test-job-index-name',
});
});
describe('user interface', () => {
beforeEach(async () => {
cleanup();
// Render the form filled out
renderSkillsBuilderWrapper();
// Click the next button to trigger "fetching" the data
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Next Step' }));
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render a <JobSillsSelectableBox> for each career interest the learner has submitted', () => {
expect(screen.getByText('Prospector')).toBeTruthy();
expect(screen.getByText('Mirror Breaker')).toBeTruthy();
const chipComponents = document.querySelectorAll('.pgn__chip');
expect(chipComponents[0].textContent).toEqual('finding shiny things');
expect(chipComponents[1].textContent).toEqual('mining');
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.recommendation.shown',
{
app_name: 'skills_builder',
category: 'skills_builder',
page: 'skills_builder',
selected_recommendations: {
job_id: 0,
job_name: 'Prospector',
courserun_keys: [
{
title: 'Mining with the Mons',
courserun_key: 'MONS101',
},
{
title: 'The Art of Warren Upkeep',
courserun_key: 'WAR101',
},
],
},
is_default: true,
},
);
// called once when "Next Step" button is clicked and then again for above event
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
});
it('renders a carousel of <Card> components', () => {
expect(screen.getByText('Course recommendations for Prospector')).toBeTruthy();
});
it('changes the recommendations based on the selected job title', () => {
fireEvent.click(screen.getByRole('radio', { name: 'Mirror Breaker' }));
expect(screen.getByText('Course recommendations for Mirror Breaker')).toBeTruthy();
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.recommendation.shown',
{
app_name: 'skills_builder',
category: 'skills_builder',
page: 'skills_builder',
selected_recommendations: {
job_id: 1,
job_name: 'Mirror Breaker',
courserun_keys: [
{
title: 'Mining with the Mons',
courserun_key: 'MONS101',
},
{
title: 'The Art of Warren Upkeep',
courserun_key: 'WAR101',
},
],
},
is_default: false,
},
);
});
it('sends an event when the "Next Step" button is clicked', () => {
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.next_step',
{
app_name: 'skills_builder',
category: 'skills_builder',
learner_data: {
current_goal: 'I want to start my career',
current_job_title: 'Goblin Lackey',
career_interests: ['Prospector', 'Mirror Breaker', 'Bombardment'],
},
},
);
});
it('fires an event when a product recommendation is clicked', () => {
fireEvent.click(screen.getByText('Mining with the Mons'));
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.recommendation.click',
{
app_name: 'skills_builder',
category: 'skills_builder',
page: 'skills_builder',
courserun_key: 'MONS101',
product_type: 'course',
selected_recommendations: {
job_id: 0,
job_name: 'Prospector',
courserun_keys: [
{
title: 'Mining with the Mons',
courserun_key: 'MONS101',
},
{
title: 'The Art of Warren Upkeep',
courserun_key: 'WAR101',
},
],
},
},
);
});
});
describe('fetch recommendations', () => {
beforeEach(() => {
cleanup();
// Render the form filled out
renderSkillsBuilderWrapper();
});
it('renders an alert if an error is thrown while fetching', async () => {
getProductRecommendations.mockImplementationOnce(() => {
throw new Error();
});
// Click the next button to trigger "fetching" the data
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Next Step' }));
});
expect(screen.getByText('We were not able to retrieve recommendations at this time. Please try again later.')).toBeTruthy();
});
});
});

View File

@@ -1,23 +0,0 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import {
screen, render, act,
} from '@testing-library/react';
import { SkillsBuilder } from '..';
import { SkillsBuilderProvider } from '../skills-builder-context';
describe('skills-builder', () => {
it('should render a Skills Builder modal with a prompt for the user', () => {
act(() => {
render(
<IntlProvider locale="en">
<SkillsBuilderProvider>
<SkillsBuilder />
</SkillsBuilderProvider>
</IntlProvider>,
);
});
expect(screen.getByText('Skills Builder')).toBeTruthy();
expect(screen.getByText('First, tell us what you want to achieve')).toBeTruthy();
});
});

View File

@@ -1,69 +0,0 @@
export const mockData = {
hits: [
{
id: 0,
name: 'Prospector'
},
{
id: 1,
name: 'Mirror Breaker'
},
],
searchJobs: [
{
id: 0,
name: 'Prospector',
skills: [
{ external_id: 0,
name: 'mining',
significance: 50,
},
{ external_id: 1,
name: 'finding shiny things',
significance: 100,
}],
},
{
id: 1,
name: 'Mirror Breaker',
skills: [
{ external_id: 0,
name: 'mining',
significance: 50,
},
{ external_id: 1,
name: 'finding shiny things',
significance: 100,
}],
},
],
productRecommendations: [
{
title: 'Mining with the Mons',
uuid: 'thisIsARandomString01',
partner: ['edx'],
card_image_url: 'https://thisIsAUrl.ForAnImage.01.jpeg',
marketing_url: 'https://thisIsAUrl.ForTheRecommendedContent.01.com',
active_run_key: 'MONS101',
owners: [
{
logoImageUrl: 'https://thisIsAUrl.ForALogoImage.01.jpeg',
}
]
},
{
title: 'The Art of Warren Upkeep',
uuid: 'thisIsARandomString02',
partner: ['edx'],
card_image_url: 'https://thisIsAUrl.ForAnImage.02.jpeg',
marketing_url: 'https://thisIsAUrl.ForTheRecommendedContent.02.com',
active_run_key: 'WAR101',
owners: [
{
logoImageUrl: 'https://thisIsAUrl.ForALogoImage.02.jpeg',
}
]
},
],
useAlgoliaSearch: [{}, {}, {}],
};

View File

@@ -1,50 +0,0 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import { SkillsBuilderModal } from '../skills-builder-modal';
import { SkillsBuilderContext } from '../skills-builder-context';
import { skillsInitialState } from '../data/reducer';
import { mockData } from './__mocks__/jobSkills.mockData';
import { getProductRecommendations, searchJobs, useAlgoliaSearch } from '../utils/search';
jest.mock('@edx/frontend-platform/logging');
jest.mock('react-instantsearch-hooks-web', () => ({
// eslint-disable-next-line react/prop-types
InstantSearch: ({ children }) => (<div>{children}</div>),
Configure: jest.fn(() => (null)),
useSearchBox: jest.fn(() => ({ refine: jest.fn() })),
useHits: jest.fn(() => ({ hits: mockData.hits })),
}));
jest.mock('../utils/search', () => ({
searchJobs: jest.fn(),
getProductRecommendations: jest.fn(),
useAlgoliaSearch: jest.fn(),
}));
searchJobs.mockReturnValue(mockData.searchJobs);
getProductRecommendations.mockReturnValue(mockData.productRecommendations);
useAlgoliaSearch.mockReturnValue(mockData.useAlgoliaSearch);
export const dispatchMock = jest.fn();
export const contextValue = {
state: {
...skillsInitialState,
},
dispatch: dispatchMock,
algolia: {
// Without this, tests would fail to destructure `searchClient` in the <JobTitleSelect> component
searchClient: {},
productSearchIndex: {},
jobSearchIndex: {},
},
};
export const SkillsBuilderWrapperWithContext = (value = contextValue) => (
<IntlProvider locale="en">
<SkillsBuilderContext.Provider value={value}>
<SkillsBuilderModal />
</SkillsBuilderContext.Provider>
</IntlProvider>
);

View File

@@ -1,110 +0,0 @@
/*
Algolia utility functions used by the Skills Builder feature.
*/
import { useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import algoliasearch from 'algoliasearch';
/*
* Utility function to create and return an Algolia client, as well as Index objects for our product and job data.
*
* @return {SearchClient} searchClient - An instantiated Algolia client
* @return {SearchIndex} productSearchIndex - An Algolia index of product data. Used to retrieve product
* recommendations for learners
* @return {SearchIndex} jobSearchIndex - An Algolia index of job taxonomy data. Used to retrieve job metadata that a
* learner is interested in.
*/
// eslint-disable-next-line import/prefer-default-export
export const useAlgoliaSearch = () => {
const config = getConfig();
const [searchClient, productSearchIndex, jobSearchIndex] = useMemo(
() => {
const client = algoliasearch(
config.ALGOLIA_APP_ID,
config.ALGOLIA_SEARCH_API_KEY,
);
const productIndex = client.initIndex(config.ALGOLIA_PRODUCT_INDEX_NAME);
const jobIndex = client.initIndex(config.ALGOLIA_JOBS_INDEX_NAME);
return [client, productIndex, jobIndex];
},
[
config.ALGOLIA_APP_ID,
config.ALGOLIA_PRODUCT_INDEX_NAME,
config.ALGOLIA_JOBS_INDEX_NAME,
config.ALGOLIA_SEARCH_API_KEY,
],
);
return [searchClient, productSearchIndex, jobSearchIndex];
};
/*
* Utility function used to format a list of data so it matches syntax Algolia expects.
*
* @param {String} facetFilterType - A string declaring the facet filter type to prepend each search item (e.g. `name`)
* @param {Array[String]} data - An array of job or skills used to query data in Algolia.
*
* @return {Array[String]} formattedData - The transformed array of data to search prepended with the facet filter type
*/
export function formatFacetFilterData(facetFilterType, data) {
const formattedData = [];
if (data) {
data.forEach(item => formattedData.push(`${facetFilterType}:${item}`));
}
return formattedData;
}
/*
* Utility function responsible for querying and returning job information based on input received from a learner.
*
* @param {SearchIndex} jobIndex - An Algolia index of taxonomy connector data used to retrieve job information a
* learner is interested in
* @param {Array[String]} jobNames - A list of job names a learner is interested in
*
* @return {Array[Object]} results - Job information retrieved from Algolia
*/
export const searchJobs = async (jobIndex, jobNames) => {
const formattedJobNames = formatFacetFilterData('name', jobNames);
try {
const { hits } = await jobIndex.search('', {
facetFilters: [
formattedJobNames,
],
});
return hits;
} catch (error) {
logError(error);
}
return [];
};
/*
* Utility function responsible for returning recommendations on products based on the skills of a job a learner is
* interested in.
*
* @param {SearchIndex} productIndex - An Algolia index of product data used to retrieve recommendations for learners.
* @param {String} productType - The type of product information you are trying to retrieve (e.g. `course` or `program`)
* @param {Array[String]} skills - An array of skill names related to a job/career a learner expressed interest in
*
* @return {Array[Object]} results - Product information retrieved from Algolia
*/
export const getProductRecommendations = async (productIndex, productType, skills) => {
const formattedSkillNames = formatFacetFilterData('skills.skill', skills);
try {
const { hits } = await productIndex.search('', {
filters: `product: "${productType}" AND language: "English"`,
facetFilters: [
formattedSkillNames,
],
});
return hits;
} catch (error) {
logError(error);
}
return [];
};

View File

@@ -1,74 +0,0 @@
import {
formatFacetFilterData,
getProductRecommendations,
searchJobs,
} from '../search';
jest.mock('@edx/frontend-platform/logging');
const mockAlgoliaResult = {
hits: [
{
key: 'test-course-key',
title: 'Test Title',
skill_names: [
{
id: 1,
name: 'Skill Name',
},
],
},
],
};
const mockIndex = {
search: jest.fn().mockImplementation(() => mockAlgoliaResult),
};
describe('Algolias utility function', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('formatFacetFilterData() should return a new array with data formatted as expected', () => {
const result = formatFacetFilterData('name', ['Organic Farmer']);
expect(result).toEqual(['name:Organic Farmer']);
});
it('searchJobs() queries Algolia with the expected search parameters', async () => {
const expectedSearchParameters = {
facetFilters: [
['name:Enchanter'],
],
};
const results = await searchJobs(mockIndex, ['Enchanter']);
expect(mockIndex.search).toHaveBeenCalledTimes(1);
expect(mockIndex.search).toHaveBeenCalledWith('', expectedSearchParameters);
expect(results).toEqual(mockAlgoliaResult.hits);
});
it('searchJobs() returns an empty array when an exception occurs querying Algolia', async () => {
const results = await searchJobs(null, ['Organic Farmer']);
expect(results).toEqual([]);
});
it('getProductRecommendations() queries Algolia with the expected search parameters', async () => {
const expectedSearchParameters = {
filters: 'product: "Course" AND language: "English"',
facetFilters: [
['skills.skill:Sword Lobbing'],
],
};
const results = await getProductRecommendations(mockIndex, 'Course', ['Sword Lobbing']);
expect(mockIndex.search).toHaveBeenCalledTimes(1);
expect(mockIndex.search).toHaveBeenCalledWith('', expectedSearchParameters);
expect(results).toEqual(mockAlgoliaResult.hits);
});
it('getProductRecommendations() returns an empty array when an exception occurs querying Algolia', async () => {
const results = await getProductRecommendations(null, 'Course', ['Management']);
expect(results).toEqual([]);
});
});

10
src/utils/hoc.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import { useParams } from 'react-router-dom';
const withParams = (WrappedComponent) => {
const WithParamsComponent = (props) => <WrappedComponent params={useParams()} {...props} />;
return WithParamsComponent;
};
export default withParams;