feat: adding lines of business
This commit is contained in:
119
package-lock.json
generated
119
package-lock.json
generated
@@ -51,6 +51,7 @@
|
||||
"@edx/frontend-build": "12.8.51",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@testing-library/react": "11.2.7",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"codecov": "3.8.3",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.7",
|
||||
@@ -4379,6 +4380,36 @@
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react-hooks": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz",
|
||||
"integrity": "sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"react-error-boundary": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.9.0 || ^17.0.0",
|
||||
"react": "^16.9.0 || ^17.0.0",
|
||||
"react-dom": "^16.9.0 || ^17.0.0",
|
||||
"react-test-renderer": "^16.9.0 || ^17.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-test-renderer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@tootallnate/once": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
|
||||
@@ -4695,9 +4726,9 @@
|
||||
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.0.tgz",
|
||||
"integrity": "sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==",
|
||||
"version": "17.0.61",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.61.tgz",
|
||||
"integrity": "sha512-bAb4j3LH2FLMCmZWow7XIKTt51+duiDjjfzR6gjhqT3ZJn9A20G9BuXELkhmM6dI6ahNpDqyL4eUAJVmR0b4JA==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
@@ -9262,14 +9293,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/filter-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz",
|
||||
@@ -12813,6 +12836,39 @@
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/mailto-link/node_modules/filter-obj": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailto-link/node_modules/query-string": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz",
|
||||
"integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.2.0",
|
||||
"filter-obj": "^1.1.0",
|
||||
"split-on-first": "^1.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mailto-link/node_modules/split-on-first": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
|
||||
@@ -14905,23 +14961,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/query-string": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.0.1.tgz",
|
||||
"integrity": "sha512-uIw3iRvHnk9to1blJCG3BTc+Ro56CBowJXKmNNAm3RulvPBzWLRqKSiiDk+IplJhsydwtuNMHi8UGQFcCLVfkA==",
|
||||
"dependencies": {
|
||||
"decode-uri-component": "^0.2.0",
|
||||
"filter-obj": "^1.1.0",
|
||||
"split-on-first": "^1.0.0",
|
||||
"strict-uri-encode": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
@@ -15222,6 +15261,22 @@
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-boundary": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
|
||||
"integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-error-overlay": {
|
||||
"version": "6.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz",
|
||||
@@ -17473,14 +17528,6 @@
|
||||
"wbuf": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/split-on-first": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/split-string": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"@edx/frontend-build": "12.8.51",
|
||||
"@edx/reactifex": "2.2.0",
|
||||
"@testing-library/react": "11.2.7",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"codecov": "3.8.3",
|
||||
"enzyme": "3.11.0",
|
||||
"enzyme-adapter-react-16": "1.15.7",
|
||||
|
||||
@@ -33,7 +33,7 @@ const SkillsBuilderHeader = ({ isMedium }) => {
|
||||
};
|
||||
|
||||
SkillsBuilderHeader.propTypes = {
|
||||
isMedium: PropTypes.func.isRequired,
|
||||
isMedium: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default SkillsBuilderHeader;
|
||||
|
||||
@@ -14,3 +14,7 @@ $breakpoint-medium: 992px;
|
||||
top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.chip-max-width {
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,9 @@ import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import RecommendationCard from './RecommendationCard';
|
||||
import messages from './messages';
|
||||
|
||||
const CarouselStack = ({ selectedRecommendations }) => {
|
||||
const CarouselStack = ({ selectedRecommendations, productTypeNames }) => {
|
||||
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,
|
||||
@@ -21,7 +20,7 @@ const CarouselStack = ({ selectedRecommendations }) => {
|
||||
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());
|
||||
const normalizeStrings = splitStrings.map(word => word[0].toUpperCase() + word.slice(1));
|
||||
|
||||
// return the array as a string joined by white spaces (i.e. Boot Camp)
|
||||
return normalizeStrings.join(' ');
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Card, Chip, Hyperlink } from '@edx/paragon';
|
||||
import {
|
||||
Card, Chip, Hyperlink,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import cardImageCapFallbackSrc from '../../images/card-imagecap-fallback.png';
|
||||
|
||||
@@ -31,7 +33,7 @@ const RecommendationCard = ({ rec, productType, handleCourseCardClick }) => {
|
||||
<Card.Section>
|
||||
{partner.map((orgName, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Chip key={index}>
|
||||
<Chip key={index} className="chip-max-width">
|
||||
{orgName}
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
@@ -14,7 +14,7 @@ const RelatedSkillsSelectableBoxSet = ({ jobSkillsList, selectedJobTitle, onChan
|
||||
const topFiveSkills = skills.sort((a, b) => b.significance - a.significance).slice(0, 5);
|
||||
return (
|
||||
topFiveSkills.map(skill => (
|
||||
<Chip key={skill.external_id}>
|
||||
<Chip key={skill.external_id} className="chip-max-width">
|
||||
{skill.name}
|
||||
</Chip>
|
||||
))
|
||||
|
||||
@@ -9,11 +9,12 @@ 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';
|
||||
|
||||
import { getRecommendations } from './data/service';
|
||||
import { useProductTypes } from './data/hooks';
|
||||
|
||||
const ViewResults = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { algolia, state } = useContext(SkillsBuilderContext);
|
||||
@@ -27,35 +28,12 @@ const ViewResults = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
|
||||
const productTypes = useProductTypes();
|
||||
|
||||
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;
|
||||
}));
|
||||
const getAllRecommendations = async () => {
|
||||
// eslint-disable-next-line max-len
|
||||
const { jobInfo, results } = await getRecommendations(jobSearchIndex, productSearchIndex, careerInterests, productTypes);
|
||||
|
||||
setJobSkillsList(jobInfo);
|
||||
setSelectedJobTitle(results[0].name);
|
||||
@@ -77,11 +55,13 @@ const ViewResults = () => {
|
||||
is_default: true,
|
||||
});
|
||||
};
|
||||
getRecommendations()
|
||||
|
||||
getAllRecommendations()
|
||||
.catch(() => {
|
||||
setFetchError(true);
|
||||
setIsLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [careerInterests, jobSearchIndex, productSearchIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,7 +136,7 @@ const ViewResults = () => {
|
||||
onChange={handleJobTitleChange}
|
||||
/>
|
||||
|
||||
<CarouselStack selectedRecommendations={selectedRecommendations} />
|
||||
<CarouselStack selectedRecommendations={selectedRecommendations} productTypeNames={productTypes} />
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
const COURSE = 'course';
|
||||
export const COURSE = 'course';
|
||||
const BOOT_CAMP = 'boot_camp';
|
||||
const EXECUTIVE_EDUCATION = 'executive_education';
|
||||
const DEGREE = '2U_degree';
|
||||
const PROGRAM = 'program';
|
||||
|
||||
/* 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
|
||||
// This array is used to determine the validity of product types as they are passed through the query string
|
||||
export const productTypes = [
|
||||
DEGREE,
|
||||
BOOT_CAMP,
|
||||
EXECUTIVE_EDUCATION,
|
||||
PROGRAM,
|
||||
COURSE,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { productTypes as acceptedProductTypes, COURSE } from './constants';
|
||||
|
||||
const defaultSetting = [COURSE];
|
||||
|
||||
/*
|
||||
* Hook that calls the useLocation() hook from react-router-dom to have a reference to the query string in the URL.
|
||||
* The returned array determines the order in which the recommendations will appear to the user.
|
||||
*
|
||||
* @return {Array[String]} productTypes - An array of strings that represent each line of business
|
||||
*/
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useProductTypes = () => {
|
||||
const { search } = useLocation();
|
||||
const checkedTypes = [];
|
||||
|
||||
if (search) {
|
||||
// remove the "?" and split the query string at "="
|
||||
const splitString = search.slice(1).split('=');
|
||||
|
||||
// if the key is not "product_types", use a default setting
|
||||
if (splitString[0] !== 'product_types') {
|
||||
return defaultSetting;
|
||||
}
|
||||
|
||||
// split productTypes string into an array at ","
|
||||
const queryProductTypes = splitString[1]?.split(',');
|
||||
|
||||
// compare each product type from the query string with a list of accepted product types
|
||||
queryProductTypes.forEach(productType => {
|
||||
if (acceptedProductTypes.includes(productType)) {
|
||||
checkedTypes.push(productType);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// if no types were set, use default setting
|
||||
return checkedTypes.length > 0 ? checkedTypes : defaultSetting;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { searchJobs, getProductRecommendations } from '../../../utils/search';
|
||||
|
||||
export async function getRecommendations(jobSearchIndex, productSearchIndex, careerInterests, productTypes) {
|
||||
const jobInfo = await searchJobs(jobSearchIndex, careerInterests);
|
||||
|
||||
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 formattedProductType = productType.replace('_', ' ');
|
||||
const response = await getProductRecommendations(productSearchIndex, formattedProductType, formattedSkills);
|
||||
|
||||
// add a new key to the recommendations object and set the value to the response
|
||||
data.recommendations[productType] = response;
|
||||
}));
|
||||
|
||||
return data;
|
||||
}));
|
||||
|
||||
return {
|
||||
jobInfo,
|
||||
results,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useProductTypes } from '../hooks';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(() => ({ search: global.query_string || '' })),
|
||||
}));
|
||||
|
||||
describe('useProductTypes', () => {
|
||||
test('returns default setting if no query string is provided', () => {
|
||||
const { result } = renderHook(() => useProductTypes());
|
||||
|
||||
const productTypes = result.current;
|
||||
|
||||
expect(productTypes).toEqual(['course']);
|
||||
});
|
||||
|
||||
test('returns a list of settings when serialized correctly', () => {
|
||||
global.query_string = '?product_types=boot_camp,course';
|
||||
|
||||
const { result } = renderHook(() => useProductTypes());
|
||||
|
||||
const productTypes = result.current;
|
||||
|
||||
expect(productTypes).toEqual(['boot_camp', 'course']);
|
||||
});
|
||||
|
||||
test('returns the default setting if query string is not serialized correctly', () => {
|
||||
global.query_string = '?legend_of_zelda=boot_camp,course';
|
||||
const { result } = renderHook(() => useProductTypes());
|
||||
|
||||
const productTypes = result.current;
|
||||
|
||||
expect(productTypes).toEqual(['course']);
|
||||
});
|
||||
|
||||
test('returns a filtered list if unrecognized values are provided', () => {
|
||||
global.query_string = '?product_types=boot_camp,course,hack_the_mainframe';
|
||||
|
||||
const { result } = renderHook(() => useProductTypes());
|
||||
|
||||
const productTypes = result.current;
|
||||
|
||||
expect(productTypes).toEqual(['boot_camp', 'course']);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,11 @@ jest.mock('react-instantsearch-hooks-web', () => ({
|
||||
useHits: jest.fn(() => ({ hits: mockData.hits })),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(() => ({ search: '?query_string=values' })),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/search', () => ({
|
||||
searchJobs: jest.fn(),
|
||||
getProductRecommendations: jest.fn(),
|
||||
|
||||
Reference in New Issue
Block a user