feat: adding lines of business

This commit is contained in:
Maxwell Frank
2023-06-12 14:22:07 +00:00
parent 93f757f3d7
commit 60fe9cff9a
15 changed files with 242 additions and 82 deletions

119
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -33,7 +33,7 @@ const SkillsBuilderHeader = ({ isMedium }) => {
};
SkillsBuilderHeader.propTypes = {
isMedium: PropTypes.func.isRequired,
isMedium: PropTypes.bool.isRequired,
};
export default SkillsBuilderHeader;

View File

@@ -14,3 +14,7 @@ $breakpoint-medium: 992px;
top: 1rem;
}
}
.chip-max-width {
max-width: 16rem;
}

View File

@@ -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(' ');

View File

@@ -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>
))}

View File

@@ -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>
))

View File

@@ -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>
)
);

View File

@@ -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,
];

View File

@@ -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;
};

View File

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

View File

@@ -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']);
});
});

View File

@@ -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(),