diff --git a/package-lock.json b/package-lock.json index 58e38af..5085b24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 00bba96..46e0aaa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/skills-builder/skills-builder-header/SkillsBuilderHeader.jsx b/src/skills-builder/skills-builder-header/SkillsBuilderHeader.jsx index d935b17..17544c0 100644 --- a/src/skills-builder/skills-builder-header/SkillsBuilderHeader.jsx +++ b/src/skills-builder/skills-builder-header/SkillsBuilderHeader.jsx @@ -33,7 +33,7 @@ const SkillsBuilderHeader = ({ isMedium }) => { }; SkillsBuilderHeader.propTypes = { - isMedium: PropTypes.func.isRequired, + isMedium: PropTypes.bool.isRequired, }; export default SkillsBuilderHeader; diff --git a/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss b/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss index 5f80033..f63f0f3 100644 --- a/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss +++ b/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss @@ -14,3 +14,7 @@ $breakpoint-medium: 992px; top: 1rem; } } + +.chip-max-width { + max-width: 16rem; +} diff --git a/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx b/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx index 1b46a91..efa8b26 100644 --- a/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx +++ b/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx @@ -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(' '); diff --git a/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx b/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx index 144501c..fbd170f 100644 --- a/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx +++ b/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx @@ -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 }) => { {partner.map((orgName, index) => ( // eslint-disable-next-line react/no-array-index-key - + {orgName} ))} diff --git a/src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx b/src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx index d10ac5e..255a168 100644 --- a/src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx +++ b/src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx @@ -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 => ( - + {skill.name} )) diff --git a/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx b/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx index 2144352..a0e4186 100644 --- a/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx +++ b/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx @@ -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} /> - + ) ); diff --git a/src/skills-builder/skills-builder-modal/view-results/data/constants.js b/src/skills-builder/skills-builder-modal/view-results/data/constants.js index c09761c..182f783 100644 --- a/src/skills-builder/skills-builder-modal/view-results/data/constants.js +++ b/src/skills-builder/skills-builder-modal/view-results/data/constants.js @@ -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, ]; diff --git a/src/skills-builder/skills-builder-modal/view-results/data/hooks.js b/src/skills-builder/skills-builder-modal/view-results/data/hooks.js new file mode 100644 index 0000000..adba093 --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/data/hooks.js @@ -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; +}; diff --git a/src/skills-builder/skills-builder-modal/view-results/data/service.js b/src/skills-builder/skills-builder-modal/view-results/data/service.js new file mode 100644 index 0000000..369edbb --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/data/service.js @@ -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, + }; +} diff --git a/src/skills-builder/skills-builder-modal/view-results/data/test/hooks.test.jsx b/src/skills-builder/skills-builder-modal/view-results/data/test/hooks.test.jsx new file mode 100644 index 0000000..e9ee5b0 --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/data/test/hooks.test.jsx @@ -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']); + }); +}); diff --git a/src/skills-builder/test/setupSkillsBuilder.jsx b/src/skills-builder/test/setupSkillsBuilder.jsx index 33819c7..2e3b6c7 100644 --- a/src/skills-builder/test/setupSkillsBuilder.jsx +++ b/src/skills-builder/test/setupSkillsBuilder.jsx @@ -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(), diff --git a/src/skills-builder/utils/search.jsx b/src/skills-builder/utils/search.js similarity index 100% rename from src/skills-builder/utils/search.jsx rename to src/skills-builder/utils/search.js diff --git a/src/skills-builder/utils/tests/search.test.jsx b/src/skills-builder/utils/tests/search.test.js similarity index 100% rename from src/skills-builder/utils/tests/search.test.jsx rename to src/skills-builder/utils/tests/search.test.js