Compare commits

..

64 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
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
57 changed files with 5052 additions and 19398 deletions

1
.env
View File

@@ -25,6 +25,7 @@ FAVICON_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=''

View File

@@ -26,6 +26,7 @@ FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
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=''

View File

@@ -18,6 +18,7 @@ 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

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

@@ -13,13 +13,13 @@ jobs:
- i18n_extract
- 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

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

View File

@@ -2,12 +2,10 @@ 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
@@ -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 -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;

22527
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -28,18 +28,19 @@
],
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.2.0",
"@edx/frontend-component-footer": "11.6.3",
"@edx/frontend-component-header": "3.6.1",
"@edx/frontend-platform": "3.4.0",
"@edx/paragon": "^20.20.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.6.0",
"algoliasearch": "4.17.1",
"classnames": "2.3.2",
"core-js": "3.27.2",
"core-js": "3.30.2",
"history": "4.10.1",
"lodash.camelcase": "4.3.0",
"lodash.get": "4.4.2",
"lodash.pick": "4.4.0",
@@ -55,18 +56,18 @@
"redux": "4.2.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.2.2",
"redux-saga": "1.2.3",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.13.11",
"reselect": "4.1.7",
"reselect": "4.1.8",
"universal-cookie": "4.0.4"
},
"devDependencies": {
"@commitlint/cli": "17.4.4",
"@commitlint/config-angular": "17.4.4",
"@commitlint/cli": "17.6.3",
"@commitlint/config-angular": "17.6.3",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-build": "12.4.19",
"@edx/reactifex": "2.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",

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,11 +34,11 @@
"profile.formcontrols.button.saved": "تم الحفظ",
"profile.visibility.who.just.me": "أنا فقط",
"profile.visibility.who.everyone": "جميع من على {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.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": "إضافة الاسم",
@@ -54,9 +54,31 @@
"profile.viewMyRecords": "عرض سجلّاتي",
"profile.loading": "يتم تحميل الملف الشخصي...",
"profile.username.description": "معلومات ملفك الشخصي تظهر لك فقط. وحده اسم المستخدم الخاص بك يظهر للآخرين على {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"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Dejanos ser tu guía",
"go.back.button": "Volver Atrás",
"next.step.button": "Próximo paso",
"exit.button": "Exit"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Laissez EDUlib être votre guide",
"go.back.button": "Retour",
"next.step.button": "Prochaine étape",
"exit.button": "Sortie"
"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'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'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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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",
@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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

@@ -58,5 +58,27 @@
"skills.builder.header.subheading": "Let edX be your guide",
"go.back.button": "Go Back",
"next.step.button": "Next Step",
"exit.button": "Exit"
"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

@@ -4,7 +4,6 @@ import 'regenerator-runtime/runtime';
import {
APP_INIT_ERROR,
APP_READY,
getConfig,
initialize,
mergeConfig,
subscribe,
@@ -16,33 +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 { SkillsBuilder } from './skills-builder';
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>
{getConfig().ENABLE_SKILLS_BUILDER && (
<Route path="/skills" component={SkillsBuilder} />
)}
<Route path="/u/:username" component={ProfilePage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="*" component={NotFoundPage} />
</Switch>
<AppRoutes />
</main>
<Footer />
</AppProvider>,
@@ -55,12 +46,7 @@ subscribe(APP_INIT_ERROR, (error) => {
});
initialize({
messages: [
appMessages,
headerMessages,
footerMessages,
],
requireAuthenticatedUser: true,
messages,
hydrateAuthenticatedUser: true,
handlers: {
config: () => {
@@ -72,6 +58,7 @@ initialize({
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');
},
},

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

@@ -2,7 +2,7 @@ import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREEST,
REMOVE_CAREER_INTEREST,
} from './constants';
export const setGoal = (payload) => ({
@@ -21,6 +21,6 @@ export const addCareerInterest = (payload) => ({
});
export const removeCareerInterest = (payload) => ({
type: REMOVE_CAREER_INTEREEST,
type: REMOVE_CAREER_INTEREST,
payload,
});

View File

@@ -2,7 +2,7 @@
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_INTEREEST = 'REMOVE_CAREER_INTEREEST';
export const REMOVE_CAREER_INTEREST = 'REMOVE_CAREER_INTEREST';
// Stepper keys
export const STEP1 = 'select-your-preferences';

View File

@@ -2,7 +2,7 @@ import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREEST,
REMOVE_CAREER_INTEREST,
} from './constants';
export function skillsReducer(state, action) {
@@ -22,7 +22,7 @@ export function skillsReducer(state, action) {
...state,
careerInterests: [...state.careerInterests, action.payload],
};
case REMOVE_CAREER_INTEREEST:
case REMOVE_CAREER_INTEREST:
return {
...state,
careerInterests: state.careerInterests.filter(interest => interest !== action.payload),

View File

@@ -3,7 +3,7 @@ import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREEST,
REMOVE_CAREER_INTEREST,
} from '../constants';
describe('skillsReducer', () => {
@@ -48,7 +48,7 @@ describe('skillsReducer', () => {
};
const returnedState = skillsReducer(
testStateWithInterest,
{ type: REMOVE_CAREER_INTEREEST, payload: newCareerInterestPayload },
{ type: REMOVE_CAREER_INTEREST, payload: newCareerInterestPayload },
);
const finalState = {
...testStateWithInterest,

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,9 +1,10 @@
import React, { useState, useContext } from 'react';
import {
Button, Container, Stepper, ModalDialog,
Button, Container, Stepper, ModalDialog, Form, Hyperlink,
} from '@edx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useHistory } from 'react-router';
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';
@@ -17,15 +18,36 @@ import ViewResults from './view-results/ViewResults';
import headerImage from '../images/headerImage.png';
const SkillsBuilderModal = () => {
const intl = useIntl();
const { formatMessage } = useIntl();
const { state } = useContext(SkillsBuilderContext);
const { careerInterests } = state;
const { currentGoal, currentJobTitle, careerInterests } = state;
const [currentStep, setCurrentStep] = useState(STEP1);
const history = useHistory();
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 onCloseHandle = () => {
history.goBack();
const nextStepHandle = () => {
setCurrentStep(STEP2);
sendActionButtonEvent('next_step');
};
const exitButtonHandle = () => {
sendActionButtonEvent('exit');
};
const closeButtonHandle = () => {
sendActionButtonEvent('close');
window.location.href = getConfig().MARKETING_SITE_SEARCH_URL;
};
return (
@@ -33,9 +55,9 @@ const SkillsBuilderModal = () => {
<ModalDialog
title="Skills Builder"
size="fullscreen"
className="skills-builder-modal"
className="skills-builder-modal bg-light-200"
isOpen
onClose={onCloseHandle}
onClose={closeButtonHandle}
>
<ModalDialog.Hero>
<ModalDialog.Hero.Background className="bg-primary-500">
@@ -49,41 +71,38 @@ const SkillsBuilderModal = () => {
<Stepper.Header />
<ModalDialog.Body>
<Container size="md">
<Stepper.Step eventKey={STEP1} title={intl.formatMessage(messages.selectPreferences)}>
<SelectPreferences />
</Stepper.Step>
<Container size="md" className="p-4.5">
<Form>
<Stepper.Step eventKey={STEP1} title={formatMessage(messages.selectPreferences)}>
<SelectPreferences />
</Stepper.Step>
<Stepper.Step eventKey={STEP2} title={intl.formatMessage(messages.reviewResults)}>
<ViewResults />
</Stepper.Step>
<Stepper.Step eventKey={STEP2} title={formatMessage(messages.reviewResults)}>
<ViewResults />
</Stepper.Step>
</Form>
</Container>
</ModalDialog.Body>
<ModalDialog.Footer>
<Stepper.ActionRow eventKey={STEP1}>
<Button variant="outline-primary" onClick={onCloseHandle}>
<FormattedMessage {...messages.goBackButton} />
</Button>
<Stepper.ActionRow.Spacer />
<Button
onClick={() => setCurrentStep(STEP2)}
onClick={nextStepHandle}
disabled={careerInterests.length === 0}
>
<FormattedMessage {...messages.nextStepButton} />
{formatMessage(messages.nextStepButton)}
</Button>
</Stepper.ActionRow>
<Stepper.ActionRow eventKey={STEP2}>
<Button
variant="outline-primary"
onClick={() => setCurrentStep(STEP1)}
>
<FormattedMessage {...messages.goBackButton} />
<Button variant="outline-primary" onClick={() => setCurrentStep(STEP1)}>
{formatMessage(messages.goBackButton)}
</Button>
<Stepper.ActionRow.Spacer />
<Button onClick={onCloseHandle}>
<FormattedMessage {...messages.exitButton} />
</Button>
<Hyperlink destination={getConfig().MARKETING_SITE_SEARCH_URL}>
<Button onClick={exitButtonHandle}>
{formatMessage(messages.exitButton)}
</Button>
</Hyperlink>
</Stepper.ActionRow>
</ModalDialog.Footer>
</ModalDialog>

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

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

View File

@@ -1,37 +1,55 @@
import React, { useContext } from 'react';
import {
Form,
Stack,
} from '@edx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
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 intl = useIntl();
const { formatMessage } = useIntl();
const { state, dispatch } = useContext(SkillsBuilderContext);
const { currentGoal } = state;
return (
<Stack gap={2}>
<h4><FormattedMessage {...messages.learningGoalPrompt} /></h4>
<Form.Group>
<Form.Control
as="select"
value={currentGoal}
onChange={(e) => dispatch(setGoal(e.target.value))}
>
<option value="">{intl.formatMessage(messages.selectLearningGoal)}</option>
<option>{intl.formatMessage(messages.learningGoalStartCareer)}</option>
<option>{intl.formatMessage(messages.learningGoalAdvanceCareer)}</option>
<option>{intl.formatMessage(messages.learningGoalChangeCareer)}</option>
<option>{intl.formatMessage(messages.learningGoalSomethingNew)}</option>
<option>{intl.formatMessage(messages.learningGoalSomethingElse)}</option>
</Form.Control>
</Form.Group>
</Stack>
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>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Form,
@@ -13,18 +13,22 @@ const JobTitleInstantSearch = (props) => {
const handleAutosuggestChange = (value) => {
setJobInput(value);
refine(value);
};
useEffect(() => {
refine(jobInput);
}, [jobInput, refine]);
return (
<Form.Autosuggest
value={jobInput}
onChange={handleAutosuggestChange}
name="job-title-suggest"
onSelected={props.onSelected}
autoComplete="off"
{...props}
>
{hits.map(job => (
<Form.AutosuggestOption key={job.id}>
<Form.AutosuggestOption key={job.id} id={job.name.replaceAll(' ', '-').toLowerCase()}>
{job.name}
</Form.AutosuggestOption>
))}

View File

@@ -1,9 +1,10 @@
import React, { useContext } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
Form, Stack,
} from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
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';
@@ -11,31 +12,63 @@ import JobTitleInstantSearch from './JobTitleInstantSearch';
import messages from './messages';
const JobTitleSelect = () => {
const { dispatch, algolia } = useContext(SkillsBuilderContext);
const { formatMessage } = useIntl();
const { state, dispatch, algolia } = useContext(SkillsBuilderContext);
const { searchClient } = algolia;
const { currentJobTitle } = state;
// Below implementation sets the job title to "student" or "looking_for_work" — this overwrites any previous selection
// This will need to be revisited when we decide what to do with this data
const handleCheckboxChange = (e) => dispatch(setCurrentJobTitle(e.target.value));
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 gap={2}>
<h4>
<FormattedMessage {...messages.jobTitlePrompt} />
</h4>
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
<JobTitleInstantSearch onSelected={(value) => dispatch(setCurrentJobTitle(value))} />
</InstantSearch>
<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">
<FormattedMessage {...messages.studentCheckboxPrompt} />
{formatMessage(messages.studentCheckboxPrompt)}
</Form.Checkbox>
<Form.Checkbox value="looking_for_work">
<FormattedMessage {...messages.currentlyLookingCheckboxPrompt} />
{formatMessage(messages.currentlyLookingCheckboxPrompt)}
</Form.Checkbox>
</Form.CheckboxSet>
</Form.Group>

View File

@@ -2,33 +2,35 @@ import React, { useContext } from 'react';
import {
Stack,
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
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={5}>
<Stack gap={4}>
<p className="lead">
<FormattedMessage {...messages.skillsBuilderDescription} />
{formatMessage(messages.skillsBuilderDescription)}
</p>
<Stack gap={4}>
<GoalSelect />
<GoalSelect />
{currentGoal && (
<JobTitleSelect />
)}
{currentGoal && (
<JobTitleSelect />
)}
{currentJobTitle && (
<CareerInterestSelect />
)}
{currentGoal && currentJobTitle && (
<CareerInterestSelect />
)}
</Stack>
</Stack>
);
};

View File

@@ -46,6 +46,11 @@ const messages = defineMessages({
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',
@@ -61,6 +66,15 @@ const messages = defineMessages({
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,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

@@ -1,7 +1,165 @@
import React from 'react';
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 = () => (
<h3>Results will render on this step</h3>
);
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,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

@@ -1,50 +1,12 @@
import { IntlProvider } from '@edx/frontend-platform/i18n';
import React from 'react';
import {
screen, render, cleanup, fireEvent, act,
screen, render, act,
} from '@testing-library/react';
import { mergeConfig } from '@edx/frontend-platform';
import { SkillsBuilder } from '..';
import { SkillsBuilderModal } from '../skills-builder-modal';
import { SkillsBuilderProvider, SkillsBuilderContext } from '../skills-builder-context';
import { skillsInitialState } from '../data/reducer';
jest.mock('react-instantsearch-hooks-web', () => ({
// eslint-disable-next-line react/prop-types
InstantSearch: ({ children }) => (<div>{children}</div>),
useSearchBox: jest.fn(() => ({ refine: jest.fn() })),
useHits: jest.fn(() => ({ hits: [{ name: 'Text File Engineer' }, { name: 'Screen Viewer' }] })),
}));
const dispatchMock = jest.fn();
const contextValue = {
state: {
...skillsInitialState,
},
dispatch: dispatchMock,
algolia: {
// Without this, tests would fail to destructure `searchClient` in the <JobTitleSelect> component
searchClient: {},
},
};
const SkillsBuilderWrapperWithContext = (value) => (
<IntlProvider locale="en">
<SkillsBuilderContext.Provider value={value}>
<SkillsBuilderModal />
</SkillsBuilderContext.Provider>
</IntlProvider>
);
import { SkillsBuilderProvider } from '../skills-builder-context';
describe('skills-builder', () => {
beforeAll(async () => {
await mergeConfig({
ALGOLIA_JOBS_INDEX_NAME: 'test-job-index-name',
});
});
beforeEach(() => cleanup());
it('should render a Skills Builder modal with a prompt for the user', () => {
act(() => {
render(
@@ -58,39 +20,4 @@ describe('skills-builder', () => {
expect(screen.getByText('Skills Builder')).toBeTruthy();
expect(screen.getByText('First, tell us what you want to achieve')).toBeTruthy();
});
it('should render the second prompt if a goal is selected', () => {
render(
SkillsBuilderWrapperWithContext(
{
...contextValue,
state: {
...contextValue.state,
currentGoal: 'I want to start my career',
},
},
),
);
expect(screen.getByText('Next, search and select your current job title')).toBeTruthy();
const checkbox = screen.getByRole('checkbox', { name: 'I\'m a student' });
fireEvent.click(checkbox);
expect(dispatchMock).toHaveBeenCalled();
});
it('should render the third prompt if a goal 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();
});
});

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

@@ -96,7 +96,7 @@ export const getProductRecommendations = async (productIndex, productType, skill
const formattedSkillNames = formatFacetFilterData('skills.skill', skills);
try {
const { hits } = await productIndex.search('', {
filters: `product:${productType}`,
filters: `product: "${productType}" AND language: "English"`,
facetFilters: [
formattedSkillNames,
],

View File

@@ -55,7 +55,7 @@ describe('Algolias utility function', () => {
it('getProductRecommendations() queries Algolia with the expected search parameters', async () => {
const expectedSearchParameters = {
filters: 'product:Course',
filters: 'product: "Course" AND language: "English"',
facetFilters: [
['skills.skill:Sword Lobbing'],
],

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,
},
});