Compare commits

..

139 Commits

Author SHA1 Message Date
Adam Stankiewicz
9615bca30a fix: use https 2023-06-01 10:42:48 -04:00
Adam Stankiewicz
5676550b16 build: demonstrate using localhost.stage.edx.org
chore: remove optimizely project id
2023-05-31 14:42:07 -04:00
renovate[bot]
45be830f18 fix(deps): update dependency core-js to v3.30.2 2023-05-29 12:15:11 +00:00
renovate[bot]
122affbb6d fix(deps): update dependency @edx/frontend-platform to v4.5.0 2023-05-29 11:07:30 +00:00
renovate[bot]
48a97b769f fix(deps): update dependency algoliasearch to v4.17.1 2023-05-29 08:24:04 +00:00
renovate[bot]
bdcc09f6ba chore(deps): update dependency @edx/frontend-build to v12.8.38 2023-05-29 06:05:47 +00:00
Maxwell Frank
ac4fb6a340 Merge pull request #757 from openedx/mfrank/fallback-img-typo
fix: fallback src typo for course cards
2023-05-25 13:01:59 -04:00
Maxwell Frank
409d365125 fix: fallback src typo for course cards 2023-05-25 16:15:20 +00:00
Jason Wesson
53985e94d8 fix: disable 'select a goal' from goal dropdown when a goal has been selected (#755)
* hide the second and third portion of skills form if currentGoal is false
2023-05-23 07:57:25 -07:00
renovate[bot]
0d9a39afd7 fix(deps): update dependency @edx/frontend-platform to v4.4.0 2023-05-23 04:18:26 +00:00
renovate[bot]
cbb860bb16 chore(deps): update dependency @edx/reactifex to v2.2.0 2023-05-23 01:34:22 +00:00
Justin Hynes
695df9aa0b fix: filter out non-English courses from recommendations (#752)
[APER-2444]

This PR updates the `getProductRecommendations` utiltiy function, adding a filter to only include English courses in our recommendations.
2023-05-22 13:13:43 -04:00
Maxwell Frank
603304b799 Merge pull request #751 from openedx/mfrank/fix-close-exit-buttons
fix: close and exit buttons
2023-05-18 13:58:42 -04:00
Maxwell Frank
d3e5931d05 fix: close and exit buttons 2023-05-18 17:52:40 +00:00
renovate[bot]
6804f7e127 fix(deps): update dependency algoliasearch to v4.17.0 2023-05-17 22:25:05 +00:00
Maxwell Frank
4b16673780 Merge pull request #750 from openedx/mfrank/reduce-event-payload
fix: reduce event payload
2023-05-17 11:12:10 -04:00
Maxwell Frank
6674025bd4 fix: reduce event payload 2023-05-17 14:38:15 +00:00
renovate[bot]
0dab2d03eb chore(deps): update commitlint monorepo to v17.6.3 2023-05-15 09:41:55 +00:00
Jenkins
df1a84feb7 chore(i18n): update translations 2023-05-14 16:40:48 -04:00
Bilal Qamar
334a9b090e feat: upgraded to node v18, added .nvmrc and updated workflows (#712)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* build: updated frontend-build, frontend-platform, component-footer & component-header packages

* refactor: updated snapshots

* refactor: updated snapshots
2023-05-12 14:09:48 -04:00
Maxwell Frank
5d06276838 Merge pull request #745 from openedx/mfrank/adding-configure-component
fix: remove filtering from current job select
2023-05-11 09:20:43 -04:00
Maxwell Frank
e391e427f1 fix: remove filtering from current job select 2023-05-11 13:05:04 +00:00
Maxwell Frank
b71328fd3f Merge pull request #744 from openedx/mfrank/adding-configure-component
fix: adding configure component to filter jobs
2023-05-10 16:40:11 -04:00
Maxwell Frank
3b9b3f8840 fix: adding configure component to filter jobs 2023-05-10 20:34:29 +00:00
Maxwell Frank
30e837306f Merge pull request #739 from openedx/mfrank/segement-events
APER-2333: Dispatch segment events from JS components
2023-05-10 15:45:35 -04:00
Maxwell Frank
c7a0c1d799 feat: adding analytics 2023-05-09 13:29:38 +00:00
renovate[bot]
337c97e3a0 chore(deps): update dependency @edx/frontend-build to v12.8.27 2023-05-08 09:32:26 +00:00
renovate[bot]
0de4496953 fix(deps): update dependency @edx/paragon to v20.32.3 2023-05-01 10:06:44 +00:00
renovate[bot]
359ae7f1fb chore(deps): update dependency @edx/frontend-build to v12.8.16 2023-05-01 07:10:23 +00:00
Omar Al-Ithawi
8d467f01dc feat: use atlas in make pull_translations (#732)
- Bump frontend-platform to bring intl-imports.js script
 - Move all i18n imports into `src/i18n/index.js` so intl-imports.js can override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL` environment variable is set.

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-04-25 09:33:22 -04:00
renovate[bot]
20debcd79e fix(deps): update dependency reselect to v4.1.8 2023-04-24 13:13:42 +00:00
renovate[bot]
6a7cbf88df fix(deps): update dependency @edx/frontend-component-footer to v11.7.4 2023-04-24 09:34:43 +00:00
Jenkins
1b3880ee1b chore(i18n): update translations 2023-04-23 16:40:44 -04:00
renovate[bot]
79cebaf6df fix(deps): update dependency @edx/frontend-component-footer to v11.7.2 2023-04-17 15:00:56 +00:00
renovate[bot]
8686af563e chore(deps): update dependency @edx/frontend-build to v12.8.10 2023-04-17 10:24:54 +00:00
renovate[bot]
85d85007d2 fix(deps): update dependency @edx/frontend-component-footer to v11.7.1 2023-04-10 13:30:36 +00:00
renovate[bot]
9276fe25ad chore(deps): update dependency @edx/frontend-build to v12.8.6 2023-04-10 09:32:52 +00:00
Jenkins
9c2dd68752 chore(i18n): update translations 2023-04-02 16:40:41 -04:00
Maxwell Frank
e4a9045e89 feat: course recommendations for Skills Builder with fix 2023-03-30 15:34:43 -04:00
Maxwell Frank
c1bbbe488a feat: skills builder course recommendations 2023-03-30 19:26:29 +00:00
Maxwell Frank
45ab2f8175 Merge pull request #725 from openedx/revert-721-mfrank/course-recommendations
Revert "feat: course recommendations for Skills Builder"
2023-03-28 14:34:51 -04:00
Maxwell Frank
d9c7096fd7 Revert "feat: course recommendations for Skills Builder" 2023-03-28 14:05:18 -04:00
Maxwell Frank
c6825393c6 feat: course recommendations for Skills Builder
feat: course recommendations for Skills Builder
2023-03-28 09:37:59 -04:00
renovate[bot]
9354f11a99 fix(deps): update dependency @edx/frontend-component-header to v3.6.5 2023-03-28 12:04:23 +00:00
renovate[bot]
e7fc8f52fb chore(deps): update dependency @commitlint/cli to v17.5.1 2023-03-28 10:16:30 +00:00
Maxwell Frank
d8c8f5d7bd feat: course recommendations for Skills Builder 2023-03-27 19:28:46 +00:00
renovate[bot]
e5355e7ac8 chore(deps): update dependency @edx/frontend-build to v12.6.2 2023-03-24 09:14:55 +00:00
Maxwell Frank
99a80d3e66 Merge pull request #718 from openedx/mfrank/authenticated-page-route
fix: authenticated page route
2023-03-22 10:56:11 -04:00
Maxwell Frank
abf9860f62 fix: authenticated page route 2023-03-20 13:39:01 +00:00
renovate[bot]
ccc62a0e48 fix(deps): update dependency redux-saga to v1.2.3 2023-03-20 10:39:41 +00:00
renovate[bot]
650d3d469f fix(deps): update dependency @edx/paragon to v20.28.5 2023-03-20 08:06:56 +00:00
renovate[bot]
ab80fd7671 fix(deps): update dependency @edx/frontend-component-header to v3.6.4 2023-03-13 13:09:42 +00:00
Mashal Malik
f55b304732 refactor: remove unused tranisfex v2 url (#704) 2023-03-13 10:45:28 +05:00
Jenkins
65971820d4 chore(i18n): update translations 2023-03-12 16:40:39 -04:00
Maxwell Frank
7da386264b Merge pull request #706 from openedx/mfrank/retrieving-job-skills-data
APER-2187 Render jobs and related skills for Skills Builder
2023-03-08 14:03:46 -05:00
Maxwell Frank
b1fe21cded feat: SkillsBuilder jobs with related skills 2023-03-07 19:07:10 +00:00
Jenkins
40225d7db3 chore(i18n): update translations 2023-03-05 15:40:37 -05:00
Maxwell Frank
b4ba5276ae Merge pull request #702 from openedx/mfrank/adding-career-interests
feat: SkillsBuilder career interests selection
2023-02-28 14:55:57 -05:00
Maxwell Frank
ddff5364ce feat: SkillsBuilder career interests selection 2023-02-28 19:51:16 +00:00
David Joy
f57f5c4725 docs: update the maintaining group to openedx/2u-aperture (#703)
The @edx/arch-fed group no longer exists - this repo is maintained by the @openedx/2u-aperture team.
2023-02-28 13:46:03 -05:00
Feanil Patel
e6feef00eb Merge pull request #696 from openedx/repo_checks/ensure_workflows
Update standard workflow files.
2023-02-27 22:36:12 -05:00
Maxwell Frank
87487e37d7 Merge pull request #693 from openedx/mfrank/goals-and-job-select-v2
APER-2186: adding goal and job title selection
2023-02-27 09:16:06 -05:00
renovate[bot]
6b451a4437 fix(deps): update dependency @edx/frontend-component-footer to v11.6.3 2023-02-27 12:33:52 +00:00
renovate[bot]
b10b31860d chore(deps): update commitlint monorepo to v17.4.4 2023-02-27 09:29:59 +00:00
Jenkins
83e2b66c77 chore(i18n): update translations 2023-02-26 15:40:39 -05:00
dependabot[bot]
6fa5681e91 build(deps): bump cookiejar from 2.1.3 to 2.1.4 (#674)
Bumps [cookiejar](https://github.com/bmeck/node-cookiejar) from 2.1.3 to 2.1.4.
- [Release notes](https://github.com/bmeck/node-cookiejar/releases)
- [Commits](https://github.com/bmeck/node-cookiejar/commits)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-24 13:07:48 -08:00
dependabot[bot]
e9e48e4eb0 build(deps): bump json5 from 1.0.1 to 1.0.2 (#661)
Bumps [json5](https://github.com/json5/json5) from 1.0.1 to 1.0.2.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v1.0.1...v1.0.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-24 13:03:26 -08:00
renovate[bot]
17b4933278 fix(deps): update dependency @edx/frontend-platform to v3 (#660)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-24 12:52:53 -08:00
Maxwell Frank
68dc8a1045 feat: first two questions of Skills Builder 2023-02-24 19:26:53 +00:00
Justin Hynes
21a3e9259d Merge pull request #695 from openedx/jhynes/aper-2262
feat: add utility function to retrieve product recommendations based on job skills
2023-02-24 09:06:26 -05:00
Justin Hynes
a6086fd4bf feat: add utility function to retrieve product recommendations based on job skills
[APER-2262]

- add a utility function to retrieve product recommendations based on skills from a job a learner is interested in
- add additional tests and coverage around new utility functions in `search.jsx`
2023-02-24 08:57:37 -05:00
edX requirements bot
0c427cf5e3 chore: update browserslist DB (#656) 2023-02-23 13:44:15 -08:00
Feanil Patel
75ea8bc207 build: Updating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 14:01:50 -05:00
Feanil Patel
47c06c0f5d build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 14:01:49 -05:00
Feanil Patel
8e0ab6db4d build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-23 14:01:48 -05:00
Jenkins
20159f140e chore(i18n): update translations 2023-02-19 15:40:37 -05:00
Justin Hynes
2d05de92af Merge pull request #691 from openedx/jhynes/aper-2261_algolia-utility-functions
feat: Add utility functions for querying job related data from Algolia
2023-02-17 14:12:54 -05:00
Justin Hynes
10f93420f4 feat: Add utility functions for querying job related data from Algolia
[APER-2261]
* rename `hooks.jsx` to `search.jsx` as this is more of a collection of utilities for working with Algolia
* add a utiltiy function for returning job info (from our "job" Algolia index) based on a list of jobs a learner is interested in
* add a utility function for formatting job names based on the syntax Algolia expects
2023-02-17 14:08:11 -05:00
Maxwell Frank
a12f91f7a5 Merge pull request #685 from openedx/mfrank/building-out-reducer
feat: adding actions and reducer for Skills Builder
2023-02-15 13:34:06 -05:00
Maxwell Frank
8f42e6fbfb feat: adding actions and reducer 2023-02-15 17:09:12 +00:00
Justin Hynes
4ecdf583ea Merge pull request #690 from openedx/jhynes/aper-2261
feat: Add Algolia support to Profile MFE for Skills Builder
2023-02-15 11:37:46 -05:00
Justin Hynes
e75864b860 feat: Add Algolia support to Profile MFE
[APER-2261]

This PR adds Algolia support to the Profile MFE and the upcoming Skills Builder feature.

* Adds new dependency for the `algoliasearch` package
* Add new config to support Algolia
* Update MFE configuration so we can access the new configuration variables
* Add hook to initialize Algolia client and return job and product Algolia indexes (based on settings)
* Update SkillsBuilderModal to add test code that displays the results of querying Algolia
2023-02-15 09:56:52 -05:00
Jenkins
a697e3c543 chore(i18n): update translations 2023-02-12 15:40:35 -05:00
Jason Wesson
04607dba1d Feat: display Learning Goal in profile (#676)
* feat: add unit tests for LearningGoal component

* feat: add LearningGoals component to Profile

---------

Co-authored-by: Jason Wesson <jwesson@2u.com>
2023-02-08 13:54:47 -08:00
Justin Hynes
6a4c8d9138 Merge pull request #682 from openedx/jhynes/aper-2258
feat: add Skills Builder Header component, refactor to use ModalDialog
2023-02-06 14:45:31 -05:00
Justin Hynes
dbf716eef5 feat: add Skills Builder Header component, refactor to use ModalDialog
[APER-2258]

This PR adds the SkillsBuilderHeader component to the profile MFE.

There was also some additional refactoring work required to display the header in the modal. The FullscreenModal component is not flexible enough to allow custom header designs to be displayed (only text). We have to pivot to using the `ModalDialog` family of components instead.
2023-02-06 13:34:04 -05:00
renovate[bot]
5eaab4f07d fix(deps): update dependency redux to v4.2.1 2023-02-06 13:31:33 +00:00
renovate[bot]
a139c2f71d fix(deps): update dependency @edx/frontend-component-footer to v11.6.2 2023-02-06 11:47:01 +00:00
Maxwell Frank
9e34fdd68d Merge pull request #680 from openedx/mfrank/adding-skills-context
feat: added skills context
2023-02-01 13:31:52 -05:00
Justin Hynes
109c5d437d Merge pull request #681 from openedx/jhynes/aper-2240-cleanup
fix: remove LR MFE URL building logic
2023-01-31 13:48:51 -05:00
Justin Hynes
25d0ecb531 fix: remove learner record MFE URL building logic
[APER-2240]

The Profile MFE no longer needs to understand anything about the Learner Record MFE. The Credentials IDA now has logic to determine if a request should be redirected to the Learner Record MFE so we can remove these changes from the Profile MFE.
2023-01-30 14:00:13 -05:00
Maxwell Frank
767af3c40b feat: added skills context 2023-01-30 15:46:15 +00:00
renovate[bot]
6a05552969 fix(deps): update dependency core-js to v3.27.2 2023-01-30 15:02:41 +00:00
renovate[bot]
628914dce3 fix(deps): update dependency @edx/frontend-component-header to v3.6.1 2023-01-30 10:28:08 +00:00
Jenkins
be7e204c91 chore(i18n): update translations 2023-01-29 15:40:36 -05:00
Bilal Qamar
c1d4c36a65 chore: update dependency @edx/frontend-build to v12.4.19 (#675) 2023-01-26 12:20:35 +05:00
renovate[bot]
ce04a04c36 fix(deps): update dependency @edx/frontend-component-footer to v11.6.1 2023-01-23 14:58:40 +00:00
renovate[bot]
346c08e5d6 chore(deps): update dependency @edx/frontend-build to v12.4.16 2023-01-23 11:27:47 +00:00
Jenkins
fe61237464 chore(i18n): update translations 2023-01-22 15:40:35 -05:00
renovate[bot]
c89285f0e8 chore(deps): update dependency glob to v8.1.0 2023-01-16 15:28:14 +00:00
renovate[bot]
7523a1edb3 chore(deps): update commitlint monorepo to v17.4.2 2023-01-16 11:27:14 +00:00
Jenkins
9f1c16a599 chore(i18n): update translations 2023-01-15 15:40:34 -05:00
Maxwell Frank
fdcef0edc8 Merge pull request #664 from openedx/mfrank/adding-skills-route
feat: set up skills builder route
2023-01-11 13:53:03 -05:00
Maxwell Frank
1f056dfac7 feat: set up skills builder route 2023-01-11 18:45:06 +00:00
renovate[bot]
2814349f37 fix(deps): update dependency @edx/brand to v1.2.0 2023-01-09 11:50:01 +00:00
renovate[bot]
a4327a98e4 chore(deps): update commitlint monorepo to v17.4.0 2023-01-09 08:15:56 +00:00
renovate[bot]
50fe0ecb6f fix(deps): update dependency core-js to v3.27.1 2023-01-02 08:58:39 +00:00
renovate[bot]
8e011fdf7b fix(deps): update dependency core-js to v3.27.0 2022-12-26 08:57:37 +00:00
dependabot[bot]
a6a35f3762 build(deps): bump loader-utils from 1.4.0 to 1.4.2 (#655)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-22 12:54:32 -08:00
renovate[bot]
259f4b2a5e chore(deps): update dependency @edx/frontend-build to v12.4.15 (#625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-22 12:34:58 -08:00
Muhammad Abdullah Waheed
65a4091e78 Automate Browserlist DB Update (#615)
* feat: added cron github action to auto update brwoserlist DB periodically

* refactor: used a shared script to update broswerslist DB, create PR and automerge it
2022-12-22 12:06:26 -08:00
Jason Wesson
e5ee7894b0 refactor: updated frontend-build & resolved eslint issues (#654)
Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
2022-12-22 12:00:53 -08:00
renovate[bot]
8f781ea867 chore(deps): update dependency glob to v8 (#622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-20 13:16:24 -08:00
dependabot[bot]
7d208a91ac build(deps): bump loader-utils from 1.4.0 to 1.4.2 (#635)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.2.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.2/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.2)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-20 13:07:00 -08:00
dependabot[bot]
80599a617f build(deps): bump minimatch and recursive-readdir (#642)
Bumps [minimatch](https://github.com/isaacs/minimatch) and [recursive-readdir](https://github.com/jergason/recursive-readdir). These dependencies needed to be updated together.

Updates `minimatch` from 3.0.4 to 3.1.2
- [Release notes](https://github.com/isaacs/minimatch/releases)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.0.4...v3.1.2)

Updates `recursive-readdir` from 2.2.2 to 2.2.3
- [Release notes](https://github.com/jergason/recursive-readdir/releases)
- [Changelog](https://github.com/jergason/recursive-readdir/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jergason/recursive-readdir/commits/v2.2.3)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-type: indirect
- dependency-name: recursive-readdir
  dependency-type: indirect

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-20 12:59:41 -08:00
dependabot[bot]
652559b157 build(deps): bump decode-uri-component from 0.2.0 to 0.2.2 (#644)
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-20 12:53:22 -08:00
renovate[bot]
5363663170 fix(deps): update dependency universal-cookie to v4 (#279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-12-20 12:44:44 -08:00
renovate[bot]
b34041f090 fix(deps): update dependency core-js to v3.26.1 2022-12-19 10:22:55 +00:00
renovate[bot]
a82e3e9918 fix(deps): update dependency @edx/frontend-component-header to v3.6.0 2022-12-19 08:03:15 +00:00
mashal-m
6c15c2a0fd fix: remove is-es6 check 2022-12-15 12:43:57 +00:00
mashal-m
da5bf2f533 build: use shared browserslist configuration and upgrade paragon version 2022-12-15 12:43:57 +00:00
renovate[bot]
e507548d48 fix(deps): update dependency @edx/frontend-component-footer to v11.6.0 2022-12-12 11:18:25 +00:00
renovate[bot]
ff6c63c86b fix(deps): update dependency redux-saga to v1.2.2 2022-12-12 08:31:07 +00:00
renovate[bot]
6e24a48570 fix(deps): update dependency @edx/frontend-component-header to v3.5.0 2022-12-05 13:32:14 +00:00
renovate[bot]
42a0d27b47 fix(deps): update dependency @edx/frontend-component-footer to v11.5.2 2022-12-05 11:02:32 +00:00
edX requirements bot
6a79462567 fix: -t flag added in pull translation command (#639) 2022-11-30 16:43:21 +05:00
renovate[bot]
afe39e8b9e fix(deps): update dependency @edx/frontend-component-header to v3.4.1 2022-11-28 12:53:13 +00:00
renovate[bot]
38afcb8a5a fix(deps): update dependency @edx/frontend-component-footer to v11.5.1 2022-11-28 09:16:22 +00:00
renovate[bot]
5bf65de9e4 chore(deps): update commitlint monorepo to v17.3.0 2022-11-28 09:09:07 +00:00
renovate[bot]
28423c261d fix(deps): update dependency regenerator-runtime to v0.13.11 2022-11-21 09:04:15 +00:00
renovate[bot]
0986fd05ab chore(deps): update commitlint monorepo to v17.2.0 2022-11-14 08:32:19 +00:00
renovate[bot]
21adb70478 fix(deps): update dependency reselect to v4.1.7 2022-11-14 08:25:41 +00:00
renovate[bot]
daca35ffbe fix(deps): update dependency redux-thunk to v2.4.2 2022-11-07 10:07:44 +00:00
renovate[bot]
9fe9164bb2 chore(deps): update dependency enzyme-adapter-react-16 to v1.15.7 2022-11-07 07:58:45 +00:00
Jenkins
09e010443d chore(i18n): update translations 2022-10-30 16:40:23 -04:00
renovate[bot]
87377a1443 fix(deps): pin dependency react-helmet to 6.1.0 2022-10-24 08:47:44 +00:00
Jenkins
5f89049506 chore(i18n): update translations 2022-10-23 16:40:27 -04:00
Diana Olarte
4f00bc43b9 feat: allow runtieme configuration (#586)
Allows frontend-app-profile to be configured at
runtime using the LMS's new MFE Configuration API.

Part of https://github.com/openedx/frontend-wg/issues/103
2022-10-17 12:13:59 -04:00
renovate[bot]
f732fa7ffc fix(deps): update dependency regenerator-runtime to v0.13.10 2022-10-17 09:48:16 +00:00
99 changed files with 9523 additions and 22751 deletions

9
.env
View File

@@ -22,8 +22,13 @@ LOGO_URL=''
LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL=''
FAVICON_URL=''
ENABLE_LEARNER_RECORD_MFE=''
LEARNER_RECORD_MFE_BASE_URL=''
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

@@ -23,8 +23,13 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
ENABLE_LEARNER_RECORD_MFE=''
LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'
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,7 +18,13 @@ 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

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

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

View File

@@ -11,16 +11,15 @@ jobs:
matrix:
npm-test:
- i18n_extract
- is-es5
- lint
- test
node: [16]
steps:
- uses: actions/checkout@v3
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: npm install -g npm@8.x.x
node-version: ${{ env.NODE_VER }}
- run: make requirements
- run: make test NPM_TESTS=build
- run: make test NPM_TESTS=${{ matrix.npm-test }}

View File

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

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

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

View File

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

5
.gitignore vendored
View File

@@ -16,4 +16,7 @@ temp/babel-plugin-react-intl
*~
/temp
/.vscode
/module.config.js
# Open edX
module.config.js
.env.development-stage

2
.nvmrc
View File

@@ -1 +1 @@
v16
18

22
Makefile Executable file → Normal file
View File

@@ -2,16 +2,14 @@ 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"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test is-es5
NPM_TESTS=build i18n_extract lint test
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
@@ -52,9 +50,23 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --languages=$(transifex_langs)
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
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
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -3,7 +3,7 @@
frontend-app-profile
====================
This is a micro-frontend application responsible for the display and updating of user profiles. Please tag **@edx/arch-fed** on any PRs or issues.
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.
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.

25
env.config.js Normal file
View File

@@ -0,0 +1,25 @@
const config = {
// Override default .env.development values
ACCESS_TOKEN_COOKIE_NAME: 'stage-edx-jwt-cookie-header-payload',
CREDENTIALS_BASE_URL: 'https://credentials.stage.edx.org',
LMS_BASE_URL: 'https://courses.stage.edx.org',
LOGIN_URL: 'https://courses.stage.edx.org/login',
LOGOUT_URL: 'https://courses.stage.edx.org/logout',
MARKETING_SITE_BASE_URL: 'https://stage.edx.org',
ORDER_HISTORY_URL: 'https://orders.stage.edx.org/orders',
ENTERPRISE_LEARNER_PORTAL_HOSTNAME: 'enterprise.stage.edx.org',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'https://courses.stage.edx.org/login_refresh',
// Paragon theme URLs
PARAGON_THEME_URLS: {
core: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css',
variants: {
light: {
url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
default: true,
dark: false,
},
},
},
};
export default config;

28960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,6 @@
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
@@ -25,22 +24,23 @@
"access": "public"
},
"browserslist": [
"last 2 versions",
"ie 11"
"extends @edx/browserslist-config"
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.2.1",
"@edx/frontend-component-header": "3.2.1",
"@edx/frontend-platform": "2.6.2",
"@edx/paragon": "19.25.3",
"@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",
"@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",
"classnames": "2.3.2",
"core-js": "3.25.5",
"core-js": "3.30.2",
"history": "4.10.1",
"lodash.camelcase": "4.3.0",
"lodash.get": "4.4.2",
"lodash.pick": "4.4.0",
@@ -48,29 +48,31 @@
"prop-types": "15.8.1",
"react": "16.14.0",
"react-dom": "16.14.0",
"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-helmet": "6.1.0",
"redux": "4.2.0",
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.2.1",
"redux-saga": "1.2.3",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"universal-cookie": "3.1.0"
"reselect": "4.1.8",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@commitlint/cli": "17.2.0",
"@commitlint/config-angular": "17.2.0",
"@edx/reactifex": "2.1.1",
"@edx/frontend-build": "12.0.6",
"@commitlint/cli": "17.6.3",
"@commitlint/config-angular": "17.6.3",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "12.8.38",
"@edx/reactifex": "2.2.0",
"@testing-library/react": "11.2.7",
"codecov": "3.8.3",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.7",
"es-check": "5.2.4",
"glob": "7.2.3",
"glob": "8.1.0",
"react-test-renderer": "16.14.0",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.4"

View File

@@ -5,16 +5,14 @@ import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
function Head({ intl }) {
return (
<Helmet>
<title>
{intl.formatMessage(messages['profile.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
}
const Head = ({ intl }) => (
<Helmet>
<title>
{intl.formatMessage(messages['profile.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
Head.propTypes = {
intl: intlShape.isRequired,

View File

@@ -1,3 +1,6 @@
import { messages as headerMessages } from '@edx/frontend-component-header';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import arMessages from './messages/ar.json';
import frMessages from './messages/fr.json';
import es419Messages from './messages/es_419.json';
@@ -11,7 +14,7 @@ import hiMessages from './messages/hi.json';
import frCAMessages from './messages/fr_CA.json';
// no need to import en messages-- they are in the defaultMessage field
const messages = {
const appMessages = {
ar: arMessages,
'es-419': es419Messages,
fr: frMessages,
@@ -25,4 +28,8 @@ const messages = {
uk: ukMessages,
};
export default messages;
export default [
headerMessages,
footerMessages,
appMessages,
];

View File

@@ -34,6 +34,11 @@
"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": "إضافة الاسم",
@@ -48,5 +53,32 @@
"profile.notfound.message": "الصفحة التي تبحث عنها غير متوفرة أو هناك خطأ في العنوان. رجاءً تحقق من العنوان و حاول مجدّدًا.",
"profile.viewMyRecords": "عرض سجلّاتي",
"profile.loading": "يتم تحميل الملف الشخصي...",
"profile.username.description": "معلومات ملفك الشخصي تظهر لك فقط. وحده اسم المستخدم الخاص بك يظهر للآخرين على {siteName}."
"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}"
}

View File

@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
@@ -48,5 +53,32 @@
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading...",
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
"profile.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}"
}

View File

@@ -1,5 +1,5 @@
{
"profile.page.title": "Profile | {siteName}",
"profile.page.title": "Perfil | {siteName}",
"profile.age.details": "Para compartir el perfil con otros {siteName} estudiantes, debe confirmar que es mayor de 13 años.",
"profile.age.set.date": "Establece tu fecha de nacimiento",
"profile.datejoined.member.since": "Miembro desde {year}",
@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Guardado",
"profile.visibility.who.just.me": "Solo yo",
"profile.visibility.who.everyone": "Todos en {siteName}",
"profile.learningGoal.learningGoal": "Objetivo de aprendizaje",
"profile.learningGoal.options.start_career": "quiero empezar mi carrera",
"profile.learningGoal.options.advance_career": "Quiero avanzar en mi carrera",
"profile.learningGoal.options.learn_something_new": "quiero aprender algo nuevo",
"profile.learningGoal.options.something_else": "Algo más",
"profile.name.full.name": "Nombre completo",
"profile.name.details": "Este es el nombre que aparecerá en tu cuenta y en tus certificados.",
"profile.name.empty": "Añade nombre",
@@ -48,5 +53,32 @@
"profile.notfound.message": "La página que estas buscando no está disponible o hay un error en la URL. Por favor, comprueba la URL y vuelve a intentarlo.",
"profile.viewMyRecords": "Ver mis registros",
"profile.loading": "Cargando perfil...",
"profile.username.description": "La información del perfil solo la visualiza usted. Solo el nombre de usuario es visible para los demás en {siteName}."
"profile.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}"
}

View File

@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Enregistré",
"profile.visibility.who.just.me": "Juste moi",
"profile.visibility.who.everyone": "Tout le monde sur {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Nom complet",
"profile.name.details": "C'est le nom qui apparaît dans votre compte et sur vos certificats.",
"profile.name.empty": "Ajouter un nom",
@@ -48,5 +53,32 @@
"profile.notfound.message": "La page que vous recherchez n'est pas disponible ou il y a une erreur dans l'URL. Veuillez vérifier l'URL et réessayer.",
"profile.viewMyRecords": "Voir mes succès",
"profile.loading": "Chargement du profil....",
"profile.username.description": "Les informations de votre profil ne sont visibles que par vous. Seul votre nom d'utilisateur est visible par les autres sur {siteName}."
"profile.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}"
}

View File

@@ -16,17 +16,17 @@
"profile.country.label": "Adresse",
"profile.country.empty": "Ajouter un emplacement",
"profile.education.empty": "Ajouter formation",
"profile.education.education": "Education",
"profile.education.education": "Formation",
"profile.education.levels.p": "Doctorat",
"profile.education.levels.m": "Maitrise ou diplôme professionnel",
"profile.education.levels.m": "Maîtrise ou diplôme professionnel",
"profile.education.levels.b": "Diplôme de baccalauréat",
"profile.education.levels.a": "Diplôme d'associé",
"profile.education.levels.hs": "Lycée / enseignement secondaire",
"profile.education.levels.jhs": "Collège / enseignement secondaire inférieur",
"profile.education.levels.el": "Enseignement primaire",
"profile.education.levels.none": "Sans diplôme",
"profile.education.levels.o": "Autre niveau d'étude",
"profile.editbutton.edit": "Modifier",
"profile.education.levels.none": "Sans formation formelle",
"profile.education.levels.o": "Autre niveau de formation",
"profile.editbutton.edit": "Éditer",
"profile.formcontrols.who.can.see": "Qui peut voir ça :",
"profile.formcontrols.button.cancel": "Annuler",
"profile.formcontrols.button.save": "Sauvegarder",
@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Sauvegardé",
"profile.visibility.who.just.me": "Juste moi",
"profile.visibility.who.everyone": "Tout le monde sur {siteName}",
"profile.learningGoal.learningGoal": "Objectif d'apprentissage",
"profile.learningGoal.options.start_career": "Je veux commencer ma carrière",
"profile.learningGoal.options.advance_career": "Je veux faire progresser ma carrière",
"profile.learningGoal.options.learn_something_new": "Je veux apprendre quelque chose de nouveau",
"profile.learningGoal.options.something_else": "Autre chose",
"profile.name.full.name": "Nom complet",
"profile.name.details": "C'est le nom qui apparaît dans votre compte et sur vos attestations.",
"profile.name.empty": "Ajouter un nom",
@@ -41,12 +46,39 @@
"profile.preferredlanguage.label": "Langue principale parlée",
"profile.profileavatar.upload-button": "Téléverser une photo",
"profile.profileavatar.remove.button": "Supprimer",
"profile.image.alt.attribute": "Avatar de profil",
"profile.image.alt.attribute": "avatar de profil",
"profile.profileavatar.change-button": "Modifier",
"profile.sociallinks.add": "Ajouter {network}",
"profile.sociallinks.social.links": "Liens vers les réseaux sociaux",
"profile.notfound.message": "La page que vous recherchez n'est pas disponible ou il y a une erreur dans l'URL. Veuillez vérifier l'URL et réessayer.",
"profile.viewMyRecords": "Afficher mes dossiers",
"profile.loading": "Chargement du profil...",
"profile.username.description": "Les informations de votre profil ne sont visibles que par vous. Seul votre nom d'utilisateur est visible par les autres sur {siteName}."
"profile.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}"
}

View File

@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
@@ -48,5 +53,32 @@
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading...",
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
"profile.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}"
}

View File

@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
@@ -48,5 +53,32 @@
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading...",
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
"profile.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}"
}

View File

@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
@@ -48,5 +53,32 @@
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading...",
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
"profile.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}"
}

View File

@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
@@ -48,5 +53,32 @@
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading...",
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
"profile.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}"
}

View File

@@ -8,7 +8,7 @@
"profile.certificate.organization.label": "From",
"profile.certificate.completion.date.label": "Completed on {date}",
"profile.no.certificates": "You don't have any certificates yet.",
"profile.certificates.my.certificates": "My Certificates",
"profile.certificates.my.certificates": "Мої сертифікати",
"profile.certificates.view.certificate": "View Certificate",
"profile.certificates.types.verified": "Verified Certificate",
"profile.certificates.types.professional": "Professional Certificate",
@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
@@ -48,5 +53,32 @@
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading...",
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
"profile.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}"
}

View File

@@ -34,6 +34,11 @@
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on {siteName}",
"profile.learningGoal.learningGoal": "Learning Goal",
"profile.learningGoal.options.start_career": "I want to start my career",
"profile.learningGoal.options.advance_career": "I want to advance my career",
"profile.learningGoal.options.learn_something_new": "I want to learn something new",
"profile.learningGoal.options.something_else": "Something else",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
@@ -48,5 +53,32 @@
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading...",
"profile.username.description": "Your profile information is only visible to you. Only your username is visible to others on {siteName}."
"profile.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}"
}

View File

@@ -15,29 +15,25 @@ import {
import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Switch } from 'react-router-dom';
import Header, { messages as headerMessages } from '@edx/frontend-component-header';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
import Header from '@edx/frontend-component-header';
import Footer from '@edx/frontend-component-footer';
import appMessages from './i18n';
import { ProfilePage, NotFoundPage } from './profile';
import messages from './i18n';
import configureStore from './data/configureStore';
import './index.scss';
import Head from './head/Head';
import AppRoutes from './routes/AppRoutes';
subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider store={configureStore()}>
<Head />
<Header />
<main>
<Switch>
<Route path="/u/:username" component={ProfilePage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="*" component={NotFoundPage} />
</Switch>
<AppRoutes />
</main>
<Footer />
</AppProvider>,
@@ -50,19 +46,19 @@ subscribe(APP_INIT_ERROR, (error) => {
});
initialize({
messages: [
appMessages,
headerMessages,
footerMessages,
],
requireAuthenticatedUser: true,
messages,
hydrateAuthenticatedUser: true,
handlers: {
config: () => {
mergeConfig({
ENABLE_LEARNER_RECORD_MFE: (process.env.ENABLE_LEARNER_RECORD_MFE || false),
LEARNER_RECORD_MFE_BASE_URL: process.env.LEARNER_RECORD_MFE_BASE_URL,
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,3 +6,6 @@
@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

@@ -4,35 +4,33 @@ import { Alert } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
function AgeMessage({ accountSettingsUrl }) {
return (
<Alert
variant="info"
dismissible={false}
show
>
<Alert.Heading id="profile.age.headline">
Your profile cannot be shared.
</Alert.Heading>
const AgeMessage = ({ accountSettingsUrl }) => (
<Alert
variant="info"
dismissible={false}
show
>
<Alert.Heading id="profile.age.headline">
Your profile cannot be shared.
</Alert.Heading>
<FormattedMessage
id="profile.age.details"
defaultMessage="To share your profile with other {siteName} learners, you must confirm that you are over the age of 13."
description="Error message"
tagName="p"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
<Alert.Link href={accountSettingsUrl}>
<FormattedMessage
id="profile.age.details"
defaultMessage="To share your profile with other {siteName} learners, you must confirm that you are over the age of 13."
description="Error message"
tagName="p"
values={{
siteName: getConfig().SITE_NAME,
}}
id="profile.age.set.date"
defaultMessage="Set your date of birth"
description="Label on a link to set birthday"
/>
<Alert.Link href={accountSettingsUrl}>
<FormattedMessage
id="profile.age.set.date"
defaultMessage="Set your date of birth"
description="Label on a link to set birthday"
/>
</Alert.Link>
</Alert>
);
}
</Alert.Link>
</Alert>
);
AgeMessage.propTypes = {
accountSettingsUrl: PropTypes.string.isRequired,

View File

@@ -1,7 +1,5 @@
import React from 'react';
function Banner() {
return <div className="profile-page-bg-banner bg-primary d-none d-md-block p-relative" />;
}
const Banner = () => <div className="profile-page-bg-banner bg-primary d-none d-md-block p-relative" />;
export default Banner;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
function DateJoined({ date }) {
const DateJoined = ({ date }) => {
if (date == null) {
return null;
}
@@ -19,7 +19,7 @@ function DateJoined({ date }) {
/>
</p>
);
}
};
DateJoined.propTypes = {
date: PropTypes.string,

View File

@@ -1,16 +1,16 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
export default function NotFoundPage() {
return (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
}
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
export default NotFoundPage;

View File

@@ -33,6 +33,7 @@ import DateJoined from './DateJoined';
import UsernameDescription from './UsernameDescription';
import PageLoading from './PageLoading';
import Banner from './Banner';
import LearningGoal from './forms/LearningGoal';
// Selectors
import { profilePageSelector } from './data/selectors';
@@ -46,10 +47,10 @@ class ProfilePage extends React.Component {
constructor(props, context) {
super(props, context);
const recordsUrl = this.getRecordsUrl(context);
const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL;
this.state = {
viewMyRecordsUrl: recordsUrl,
viewMyRecordsUrl: credentialsBaseUrl ? `${credentialsBaseUrl}/records` : null,
accountSettingsUrl: `${context.config.LMS_BASE_URL}/account/settings`,
};
@@ -92,19 +93,6 @@ class ProfilePage extends React.Component {
this.props.updateDraft(name, value);
}
getRecordsUrl(context) {
let recordsUrl = null;
if (getConfig().ENABLE_LEARNER_RECORD_MFE) {
recordsUrl = getConfig().LEARNER_RECORD_MFE_BASE_URL;
} else {
const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL;
recordsUrl = credentialsBaseUrl ? `${credentialsBaseUrl}/records` : null;
}
return recordsUrl;
}
isYOBDisabled() {
const { yearOfBirth } = this.props;
const currentYear = new Date().getFullYear();
@@ -184,6 +172,8 @@ class ProfilePage extends React.Component {
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
learningGoal,
visibilityLearningGoal,
languageProficiencies,
visibilityLanguageProficiencies,
visibilityCourseCertificates,
@@ -278,6 +268,14 @@ class ProfilePage extends React.Component {
formId="bio"
{...commonFormProps}
/>
{getConfig().ENABLE_SKILLS_BUILDER_PROFILE && (
<LearningGoal
learningGoal={learningGoal}
visibilityLearningGoal={visibilityLearningGoal}
formId="learningGoal"
{...commonFormProps}
/>
)}
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
@@ -346,6 +344,10 @@ ProfilePage.propTypes = {
})),
visibilitySocialLinks: PropTypes.string.isRequired,
// Learning Goal form data
learningGoal: PropTypes.string,
visibilityLearningGoal: PropTypes.string.isRequired,
// Other data we need
profileImage: PropTypes.shape({
src: PropTypes.string,
@@ -390,6 +392,7 @@ ProfilePage.defaultProps = {
socialLinks: [],
draftSocialLinksByPlatform: {},
bio: null,
learningGoal: null,
languageProficiencies: [],
courseCertificates: null,
requiresParentalConsent: null,

View File

@@ -66,21 +66,19 @@ beforeEach(() => {
analytics.sendTrackingLogEvent.mockReset();
});
function ProfilePageWrapper({
const ProfilePageWrapper = ({
contextValue, store, match, requiresParentalConsent,
}) {
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={store}>
<ProfilePage {...requiredProfilePageProps} match={match} requiresParentalConsent={requiresParentalConsent} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
}
}) => (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={store}>
<ProfilePage {...requiredProfilePageProps} match={match} requiresParentalConsent={requiresParentalConsent} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
ProfilePageWrapper.defaultProps = {
match: { params: { username: 'staff' } },

View File

@@ -4,22 +4,20 @@ import { VisibilityOff } from '@edx/paragon/icons';
import { Icon } from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
function UsernameDescription() {
return (
<div className="d-flex align-items-center mt-3 mb-2rem">
<Icon src={VisibilityOff} className="icon-visibility-off" />
<div className="username-description">
<FormattedMessage
id="profile.username.description"
defaultMessage="Your profile information is only visible to you. Only your username is visible to others on {siteName}."
description="A description of the username field"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
</div>
const UsernameDescription = () => (
<div className="d-flex align-items-center mt-3 mb-2rem">
<Icon src={VisibilityOff} className="icon-visibility-off" />
<div className="username-description">
<FormattedMessage
id="profile.username.description"
defaultMessage="Your profile information is only visible to you. Only your username is visible to others on {siteName}."
description="A description of the username field"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
</div>
);
}
</div>
);
export default UsernameDescription;

View File

@@ -12,7 +12,8 @@ module.exports = {
imageUrlMedium: null,
imageUrlLarge: null
},
levelOfEducation: null
levelOfEducation: null,
learningGoal: null
},
profilePage: {
errors: {},

View File

@@ -42,7 +42,8 @@ module.exports = {
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom'
accountPrivacy: 'custom',
learningGoal: null,
},
profilePage: {
errors: {},
@@ -91,7 +92,8 @@ module.exports = {
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom'
accountPrivacy: 'custom',
learningGoal: null,
},
preferences: {
visibilityUserLocation: 'all_users',
@@ -104,7 +106,8 @@ module.exports = {
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom'
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
},
courseCertificates: [
{

View File

@@ -42,7 +42,8 @@ module.exports = {
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom'
accountPrivacy: 'custom',
learningGoal: 'advance_career',
},
profilePage: {
errors: {},
@@ -83,7 +84,8 @@ module.exports = {
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: false
isLoadingProfile: false,
learningGoal: 'advance_career',
},
router: {
location: {

View File

@@ -42,7 +42,8 @@ module.exports = {
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom'
accountPrivacy: 'custom',
learningGoal: 'advance_career'
},
profilePage: {
errors: {},
@@ -91,7 +92,8 @@ module.exports = {
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom'
accountPrivacy: 'custom',
learningGoal: 'advance_career'
},
preferences: {
visibilityUserLocation: 'all_users',
@@ -104,7 +106,8 @@ module.exports = {
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom'
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
},
courseCertificates: [
{

View File

@@ -60,7 +60,7 @@ exports[`<ProfilePage /> Renders correctly in various states test country edit w
className="profile-avatar-menu-container"
>
<div
className="dropdown"
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
@@ -2441,7 +2441,7 @@ exports[`<ProfilePage /> Renders correctly in various states test education edit
className="profile-avatar-menu-container"
>
<div
className="dropdown"
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
@@ -3616,7 +3616,7 @@ exports[`<ProfilePage /> Renders correctly in various states test preferreded la
className="profile-avatar-menu-container"
>
<div
className="dropdown"
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
@@ -5898,7 +5898,7 @@ exports[`<ProfilePage /> Renders correctly in various states viewing own profile
className="profile-avatar-menu-container"
>
<div
className="dropdown"
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
@@ -6948,7 +6948,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
className="profile-avatar-menu-container"
>
<div
className="dropdown"
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
@@ -8065,7 +8065,7 @@ exports[`<ProfilePage /> Renders correctly in various states while saving an edi
className="profile-avatar-menu-container"
>
<div
className="dropdown"
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button
@@ -9188,7 +9188,7 @@ exports[`<ProfilePage /> Renders correctly in various states without credentials
className="profile-avatar-menu-container"
>
<div
className="dropdown"
className="pgn__dropdown pgn__dropdown-light dropdown"
data-testid="dropdown"
>
<button

View File

@@ -0,0 +1,7 @@
const mockData = {
learningGoal: 'advance_career',
editMode: 'static',
visibilityLearningGoal: 'private',
};
export default mockData;

View File

@@ -0,0 +1,92 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import get from 'lodash.get';
// Mock Data
import mockData from '../data/mock_data';
import messages from './LearningGoal.messages';
// Components
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors';
const LearningGoal = (props) => {
let { learningGoal, editMode, visibilityLearningGoal } = props;
const { intl } = props;
if (!learningGoal) {
learningGoal = mockData.learningGoal;
}
if (!editMode || editMode === 'empty') { // editMode defaults to 'empty', not sure why yet
editMode = mockData.editMode;
}
if (!visibilityLearningGoal) {
visibilityLearningGoal = mockData.visibilityLearningGoal;
}
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])}
showVisibility={visibilityLearningGoal !== null}
visibility={visibilityLearningGoal}
/>
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])} />
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
}}
/>
);
};
LearningGoal.propTypes = {
// From Selector
learningGoal: PropTypes.oneOf(['advance_career', 'start_career', 'learn_something_new', 'something_else']),
visibilityLearningGoal: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editable', 'static']),
// i18n
intl: intlShape.isRequired,
};
LearningGoal.defaultProps = {
editMode: 'static',
learningGoal: null,
visibilityLearningGoal: 'private',
};
export default connect(
editableFormSelector,
{},
)(injectIntl(LearningGoal));

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.learningGoal.learningGoal': {
id: 'profile.learningGoal.learningGoal',
defaultMessage: 'Learning Goal',
description: 'A section of a user profile that displays their current learning goal.',
},
'profile.learningGoal.options.start_career': {
id: 'profile.learningGoal.options.start_career',
defaultMessage: 'I want to start my career',
description: 'Selected by user if their goal is to start their career.',
},
'profile.learningGoal.options.advance_career': {
id: 'profile.learningGoal.options.advance_career',
defaultMessage: 'I want to advance my career',
description: 'Selected by user if their goal is to advance their career.',
},
'profile.learningGoal.options.learn_something_new': {
id: 'profile.learningGoal.options.learn_something_new',
defaultMessage: 'I want to learn something new',
description: 'Selected by user if their goal is to learn something new.',
},
'profile.learningGoal.options.something_else': {
id: 'profile.learningGoal.options.something_else',
defaultMessage: 'Something else',
description: 'Selected by user if their goal is not described by the other choices.',
},
});
export default messages;

View File

@@ -0,0 +1,122 @@
import PropTypes from 'prop-types';
import React, { useMemo } from 'react';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import messages from '../../i18n';
import viewOwnProfileMockStore from '../__mocks__/viewOwnProfile.mockStore';
import savingEditedBioMockStore from '../__mocks__/savingEditedBio.mockStore';
import LearningGoal from './LearningGoal';
const mockStore = configureMockStore([thunk]);
// props to be passed down to LearningGoal component
const requiredLearningGoalProps = {
formId: 'learningGoal',
learningGoal: 'advance_career',
drafts: {},
visibilityLearningGoal: 'private',
editMode: 'static',
saveState: null,
error: null,
openHandler: jest.fn(),
};
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
const LearningGoalWrapper = (props) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={props.store}>
<LearningGoal {...props} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapper.defaultProps = {
store: mockStore(viewOwnProfileMockStore),
};
LearningGoalWrapper.propTypes = {
store: PropTypes.shape({}),
};
const LearningGoalWrapperWithStore = ({ store }) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={mockStore(store)}>
<LearningGoal {...requiredLearningGoalProps} formId="learningGoal" />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapperWithStore.defaultProps = {
store: mockStore(savingEditedBioMockStore),
};
LearningGoalWrapperWithStore.propTypes = {
store: PropTypes.shape({}),
};
describe('<LearningGoal />', () => {
describe('renders the current learning goal', () => {
it('renders "I want to advance my career"', () => {
const learningGoalRenderer = renderer.create(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
const learningGoalInstance = learningGoalRenderer.root;
expect(learningGoalInstance.findByProps({ className: 'lead' }).children).toEqual(['I want to advance my career']);
});
it('renders "Something else"', () => {
requiredLearningGoalProps.learningGoal = 'something_else';
const learningGoalRenderer = renderer.create(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
const learningGoalInstance = learningGoalRenderer.root;
expect(learningGoalInstance.findByProps({ className: 'lead' }).children).toEqual(['Something else']);
});
});
});

View File

@@ -244,14 +244,12 @@ export default connect(
{},
)(injectIntl(SocialLinks));
function SocialLink({ url, name, platform }) {
return (
<a href={url} className="font-weight-bold">
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
{name}
</a>
);
}
const SocialLink = ({ url, name, platform }) => (
<a href={url} className="font-weight-bold">
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
{name}
</a>
);
SocialLink.propTypes = {
url: PropTypes.string.isRequired,
@@ -259,9 +257,9 @@ SocialLink.propTypes = {
name: PropTypes.string.isRequired,
};
function EditableListItem({
const EditableListItem = ({
url, platform, onClickEmptyContent, name,
}) {
}) => {
const linkDisplay = url ? (
<SocialLink name={name} url={url} platform={platform} />
) : (
@@ -269,7 +267,7 @@ function EditableListItem({
);
return <li className="form-group">{linkDisplay}</li>;
}
};
EditableListItem.propTypes = {
url: PropTypes.string,
@@ -282,24 +280,22 @@ EditableListItem.defaultProps = {
onClickEmptyContent: null,
};
function EditingListItem({
const EditingListItem = ({
platform, name, value, onChange, error,
}) {
return (
<li className="form-group">
<label htmlFor={`social-${platform}`}>{name}</label>
<input
className={classNames('form-control', { 'is-invalid': Boolean(error) })}
type="text"
id={`social-${platform}`}
name={platform}
value={value || ''}
onChange={onChange}
aria-describedby="social-error-feedback"
/>
</li>
);
}
}) => (
<li className="form-group">
<label htmlFor={`social-${platform}`}>{name}</label>
<input
className={classNames('form-control', { 'is-invalid': Boolean(error) })}
type="text"
id={`social-${platform}`}
name={platform}
value={value || ''}
onChange={onChange}
aria-describedby="social-error-feedback"
/>
</li>
);
EditingListItem.propTypes = {
platform: PropTypes.string.isRequired,
@@ -314,35 +310,31 @@ EditingListItem.defaultProps = {
error: null,
};
function EmptyListItem({ onClick, name }) {
return (
<li className="mb-4">
<EmptyContent onClick={onClick}>
<FormattedMessage
id="profile.sociallinks.add"
defaultMessage="Add {network}"
values={{
network: name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
</EmptyContent>
</li>
);
}
const EmptyListItem = ({ onClick, name }) => (
<li className="mb-4">
<EmptyContent onClick={onClick}>
<FormattedMessage
id="profile.sociallinks.add"
defaultMessage="Add {network}"
values={{
network: name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
</EmptyContent>
</li>
);
EmptyListItem.propTypes = {
name: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
function StaticListItem({ name, url, platform }) {
return (
<li className="mb-2">
<SocialLink name={name} url={url} platform={platform} />
</li>
);
}
const StaticListItem = ({ name, url, platform }) => (
<li className="mb-2">
<SocialLink name={name} url={url} platform={platform} />
</li>
);
StaticListItem.propTypes = {
name: PropTypes.string.isRequired,

View File

@@ -47,7 +47,7 @@ configureI18n({
messages,
});
function SocialLinksWrapper(props) {
const SocialLinksWrapper = (props) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
@@ -63,7 +63,7 @@ function SocialLinksWrapper(props) {
</IntlProvider>
</AppContext.Provider>
);
}
};
SocialLinksWrapper.defaultProps = {
store: mockStore(savingEditedBio),
@@ -73,7 +73,7 @@ SocialLinksWrapper.propTypes = {
store: PropTypes.shape({}),
};
function SocialLinksWrapperWithStore({ store }) {
const SocialLinksWrapperWithStore = ({ store }) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
@@ -89,7 +89,7 @@ function SocialLinksWrapperWithStore({ store }) {
</IntlProvider>
</AppContext.Provider>
);
}
};
SocialLinksWrapperWithStore.defaultProps = {
store: mockStore(savingEditedBio),

View File

@@ -7,22 +7,20 @@ import { Button } from '@edx/paragon';
import messages from './EditButton.messages';
function EditButton({
const EditButton = ({
onClick, className, style, intl,
}) {
return (
<Button
variant="link"
size="sm"
className={className}
onClick={onClick}
style={style}
>
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
{intl.formatMessage(messages['profile.editbutton.edit'])}
</Button>
);
}
}) => (
<Button
variant="link"
size="sm"
className={className}
onClick={onClick}
style={style}
>
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
{intl.formatMessage(messages['profile.editbutton.edit'])}
</Button>
);
export default injectIntl(EditButton);

View File

@@ -4,24 +4,22 @@ import PropTypes from 'prop-types';
import EditButton from './EditButton';
import { Visibility } from './Visibility';
function EditableItemHeader({
const EditableItemHeader = ({
content,
showVisibility,
visibility,
showEditButton,
onClickEdit,
headingId,
}) {
return (
<div className="editable-item-header mb-2">
<h2 className="edit-section-header" id={headingId}>
{content}
{showEditButton ? <EditButton style={{ marginTop: '-.35rem' }} className="float-right px-0" onClick={onClickEdit} /> : null}
</h2>
{showVisibility ? <p className="mb-0"><Visibility to={visibility} /></p> : null}
</div>
);
}
}) => (
<div className="editable-item-header mb-2">
<h2 className="edit-section-header" id={headingId}>
{content}
{showEditButton ? <EditButton style={{ marginTop: '-.35rem' }} className="float-right px-0" onClick={onClickEdit} /> : null}
</h2>
{showVisibility ? <p className="mb-0"><Visibility to={visibility} /></p> : null}
</div>
);
export default EditableItemHeader;

View File

@@ -3,24 +3,22 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
function EmptyContent({ children, onClick, showPlusIcon }) {
return (
<div>
{onClick ? (
<button
type="button"
className="pl-0 text-left btn btn-link"
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') { onClick(); } }}
tabIndex={0}
>
{showPlusIcon ? <FontAwesomeIcon size="xs" className="mr-2" icon={faPlus} /> : null}
{children}
</button>
) : children}
</div>
);
}
const EmptyContent = ({ children, onClick, showPlusIcon }) => (
<div>
{onClick ? (
<button
type="button"
className="pl-0 text-left btn btn-link"
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') { onClick(); } }}
tabIndex={0}
>
{showPlusIcon ? <FontAwesomeIcon size="xs" className="mr-2" icon={faPlus} /> : null}
{children}
</button>
) : children}
</div>
);
export default EmptyContent;

View File

@@ -7,9 +7,9 @@ import messages from './FormControls.messages';
import { VisibilitySelect } from './Visibility';
function FormControls({
const FormControls = ({
cancelHandler, changeHandler, visibility, visibilityId, saveState, intl,
}) {
}) => {
// Eliminate error/failed state for save button
const buttonState = saveState === 'error' ? null : saveState;
@@ -57,7 +57,7 @@ function FormControls({
</div>
</div>
);
}
};
export default injectIntl(FormControls);

View File

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

View File

@@ -7,7 +7,7 @@ import { faEyeSlash, faEye } from '@fortawesome/free-regular-svg-icons';
import messages from './Visibility.messages';
function Visibility({ to, intl }) {
const Visibility = ({ to, intl }) => {
const icon = to === 'private' ? faEyeSlash : faEye;
const label = to === 'private'
? intl.formatMessage(messages['profile.visibility.who.just.me'])
@@ -18,7 +18,7 @@ function Visibility({ to, intl }) {
<FontAwesomeIcon icon={icon} /> {label}
</span>
);
}
};
Visibility.propTypes = {
to: PropTypes.oneOf(['private', 'all_users']),
@@ -30,7 +30,7 @@ Visibility.defaultProps = {
to: 'private',
};
function VisibilitySelect({ intl, className, ...props }) {
const VisibilitySelect = ({ intl, className, ...props }) => {
const { value } = props;
const icon = value === 'private' ? faEyeSlash : faEye;
@@ -49,7 +49,7 @@ function VisibilitySelect({ intl, className, ...props }) {
</select>
</span>
);
}
};
VisibilitySelect.propTypes = {
id: PropTypes.string,

22
src/routes/AppRoutes.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
AuthenticatedPageRoute,
PageRoute,
} from '@edx/frontend-platform/react';
import { Switch } from 'react-router-dom';
import { ProfilePage, NotFoundPage } from '../profile';
import { SkillsBuilder } from '../skills-builder';
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>
);
export default AppRoutes;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig } from '@edx/frontend-platform';
import { Router } from 'react-router';
import { render, screen } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import AppRoutes from './AppRoutes';
jest.mock('@edx/frontend-platform/analytics');
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>),
}));
jest.mock('../skills-builder', () => ({
SkillsBuilder: () => (<div>Skills Builder</div>),
}));
const RoutesWithProvider = (context, history) => (
<AppContext.Provider value={context}>
<Router history={history}>
<AppRoutes />
</Router>
</AppContext.Provider>
);
const unauthenticatedUser = {
authenticatedUser: null,
config: getConfig(),
};
describe('routes', () => {
let history;
beforeEach(() => {
history = createMemoryHistory();
});
test('Profile page should redirect for unauthenticated users', () => {
history.push('/u/edx');
render(
RoutesWithProvider(unauthenticatedUser, history),
);
expect(getLoginRedirectUrl).toHaveBeenCalled();
});
test('Profile page should be accessible for authenticated users', () => {
history.push('/u/edx');
render(
RoutesWithProvider(
{
authenticatedUser: {
username: 'edx',
email: 'edx@example.com',
},
config: getConfig(),
},
history,
),
);
expect(screen.getByText('Profile page')).toBeTruthy();
});
test('Skills Builder page should be accessible to unauthenticated users', () => {
history.push('/skills');
render(
RoutesWithProvider(unauthenticatedUser, history),
);
expect(screen.getByText('Skills Builder')).toBeTruthy();
});
test('should show NotFound page for a bad route', () => {
history.push('/nonMatchingRoute');
render(
RoutesWithProvider(unauthenticatedUser, history),
);
expect(screen.getByText('Not found page')).toBeTruthy();
});
});

View File

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

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

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

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

@@ -0,0 +1,60 @@
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.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

View File

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

View File

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

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

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

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

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

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

@@ -0,0 +1,74 @@
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([]);
});
});

8
webpack.dev.config.js Normal file
View File

@@ -0,0 +1,8 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('webpack-dev', {
devServer: {
allowedHosts: 'all',
https: true,
},
});