From c1bbbe488aa786bf42f8067b7de9755455bc58dc Mon Sep 17 00:00:00 2001 From: Maxwell Frank Date: Thu, 30 Mar 2023 18:39:12 +0000 Subject: [PATCH] feat: skills builder course recommendations --- .../images/card-imagecap-fallback.png | Bin 0 -> 16761 bytes .../CareerInterestSelect.jsx | 29 +-- .../select-preferences/GoalSelect.jsx | 42 ++--- .../JobTitleInstantSearch.jsx | 10 +- .../select-preferences/JobTitleSelect.jsx | 25 +-- .../select-preferences/messages.js | 9 +- .../test/SelectPreferences.test.jsx | 109 +++++++++++ .../view-results/CarouselStack.jsx | 52 ++++++ .../view-results/RecommendationCard.jsx | 54 ++++++ .../RelatedSkillsSelectableBoxSet.jsx | 2 +- .../view-results/ViewResults.jsx | 56 +++++- .../view-results/data/constants.js | 11 ++ .../view-results/messages.js | 10 + .../view-results/test/ViewResults.test.jsx | 79 ++++++++ .../test/SkillsBuilder.test.jsx | 174 +----------------- .../test/__mocks__/jobSkills.mockData.js | 43 ++++- .../test/setupSkillsBuilder.jsx | 49 +++++ src/skills-builder/utils/search.jsx | 2 +- .../utils/tests/search.test.jsx | 2 +- 19 files changed, 507 insertions(+), 251 deletions(-) create mode 100644 src/skills-builder/images/card-imagecap-fallback.png create mode 100644 src/skills-builder/skills-builder-modal/select-preferences/test/SelectPreferences.test.jsx create mode 100644 src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx create mode 100644 src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx create mode 100644 src/skills-builder/skills-builder-modal/view-results/data/constants.js create mode 100644 src/skills-builder/skills-builder-modal/view-results/test/ViewResults.test.jsx create mode 100644 src/skills-builder/test/setupSkillsBuilder.jsx diff --git a/src/skills-builder/images/card-imagecap-fallback.png b/src/skills-builder/images/card-imagecap-fallback.png new file mode 100644 index 0000000000000000000000000000000000000000..1b031024472fa880abb59a94c8666fbf8135e9c8 GIT binary patch literal 16761 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 ghg(29h*%xz{{R30 literal 0 HcmV?d00001 diff --git a/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestSelect.jsx b/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestSelect.jsx index 74fbd1f..aedbad5 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestSelect.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestSelect.jsx @@ -1,7 +1,9 @@ import React, { useContext } from 'react'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { Stack, Row, Col } from '@edx/paragon'; +import { + Stack, Row, Col, Form, +} from '@edx/paragon'; import { InstantSearch } from 'react-instantsearch-hooks-web'; import JobTitleInstantSearch from './JobTitleInstantSearch'; import CareerInterestCard from './CareerInterestCard'; @@ -16,25 +18,24 @@ const CareerInterestSelect = () => { const { searchClient } = algolia; const handleCareerInterestSelect = (value) => { - // By checking for a value to exist, we avoid adding a null value to the careerInterests array - // The 'onSelected' function is fired during every 'onChange' event - // A null value was being passed to this function whenever the search box received input, resulting in empty cards - if (value && careerInterests.length < 3) { + if (!careerInterests.includes(value) && careerInterests.length < 3) { dispatch(addCareerInterest(value)); } }; return ( -

- {formatMessage(messages.careerInterestPrompt)} -

- - - + +

+ {formatMessage(messages.careerInterestPrompt)} +

+ + + +
{careerInterests.map((interest, index) => ( // eslint-disable-next-line react/no-array-index-key diff --git a/src/skills-builder/skills-builder-modal/select-preferences/GoalSelect.jsx b/src/skills-builder/skills-builder-modal/select-preferences/GoalSelect.jsx index 77f5fb0..b5e8cfb 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/GoalSelect.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/GoalSelect.jsx @@ -1,7 +1,6 @@ import React, { useContext } from 'react'; import { Form, - Stack, } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { setGoal } from '../../data/actions'; @@ -14,27 +13,26 @@ const GoalDropdown = () => { const { currentGoal } = state; return ( - -

- {formatMessage(messages.learningGoalPrompt)} -

- - dispatch(setGoal(e.target.value))} - data-testid="goal-select-dropdown" - > - - - - - - - - -
- + + +

+ {formatMessage(messages.learningGoalPrompt)} +

+
+ dispatch(setGoal(e.target.value))} + data-testid="goal-select-dropdown" + > + + + + + + + +
); }; diff --git a/src/skills-builder/skills-builder-modal/select-preferences/JobTitleInstantSearch.jsx b/src/skills-builder/skills-builder-modal/select-preferences/JobTitleInstantSearch.jsx index 5c29d7b..138c61e 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/JobTitleInstantSearch.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/JobTitleInstantSearch.jsx @@ -24,12 +24,11 @@ const JobTitleInstantSearch = (props) => { value={jobInput} onChange={handleAutosuggestChange} name="job-title-suggest" - onSelected={props.onSelected} autoComplete="off" - placeholder={props.placeholder} + {...props} > {hits.map(job => ( - + {job.name} ))} @@ -37,13 +36,8 @@ const JobTitleInstantSearch = (props) => { ); }; -JobTitleInstantSearch.defaultProps = { - placeholder: '', -}; - JobTitleInstantSearch.propTypes = { onSelected: PropTypes.func.isRequired, - placeholder: PropTypes.string, }; export default JobTitleInstantSearch; diff --git a/src/skills-builder/skills-builder-modal/select-preferences/JobTitleSelect.jsx b/src/skills-builder/skills-builder-modal/select-preferences/JobTitleSelect.jsx index 35552ff..ea20aa3 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/JobTitleSelect.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/JobTitleSelect.jsx @@ -12,9 +12,8 @@ import messages from './messages'; const JobTitleSelect = () => { const { formatMessage } = useIntl(); - const { state, dispatch, algolia } = useContext(SkillsBuilderContext); + const { dispatch, algolia } = useContext(SkillsBuilderContext); const { searchClient } = algolia; - const { currentJobTitle } = state; const handleCurrentJobTitleSelect = (value) => { dispatch(setCurrentJobTitle(value)); @@ -25,16 +24,18 @@ const JobTitleSelect = () => { const handleCheckboxChange = (e) => dispatch(setCurrentJobTitle(e.target.value)); return ( - -

- {formatMessage(messages.jobTitlePrompt)} -

- - - + + +

+ {formatMessage(messages.jobTitlePrompt)} +

+ + + +
{ + 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 expectedJobTitle = { + payload: 'Student', + 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); + + expect(screen.getByText('Next, search and select your current job title')).toBeTruthy(); + expect(dispatchMock).toHaveBeenCalledWith(expectedGoal); + expect(dispatchMock).toHaveBeenCalledWith(expectedJobTitle); + }); + + 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 for each career interest', () => { + render( + SkillsBuilderWrapperWithContext( + { + ...contextValue, + state: { + ...contextValue.state, + currentGoal: 'I want to start my career', + currentJobTitle: 'Goblin Lackey', + careerInterests: ['Prospector', 'Mirror Breaker', 'Bombardment'], + }, + }, + ), + ); + expect(screen.getByText('Prospector')).toBeTruthy(); + expect(screen.getByText('Mirror Breaker')).toBeTruthy(); + expect(screen.getByText('Bombardment')).toBeTruthy(); + }); + }); + + describe('controlled behavior', () => { + it('should remove a 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); + }); + }); +}); diff --git a/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx b/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx new file mode 100644 index 0000000..78a6ced --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { CardCarousel } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import RecommendationCard from './RecommendationCard'; +import messages from './messages'; + +const CarouselStack = ({ selectedRecommendations }) => { + const { formatMessage } = useIntl(); + const { name: jobName, recommendations } = selectedRecommendations; + const productTypeNames = Object.keys(recommendations); + + 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) => ( +

+ {formatMessage(messages.productRecommendationsHeaderText, { + productType: normalizeProductTypeName(productType), + jobName, + })} +

+ ); + + return ( + productTypeNames.map(productType => ( + + {recommendations[productType].map(rec => ( + + ))} + + ))); +}; + +export default CarouselStack; diff --git a/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx b/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx new file mode 100644 index 0000000..dd56894 --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Card, Chip, Hyperlink } from '@edx/paragon'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import cardImageCapFallbackSrc from '../../images/card-imagecap-fallback.png'; + +const RecommendationCard = ({ rec }) => { + const { + card_image_url: cardImageUrl, + marketing_url: marketingUrl, + owners, + partner, + title, + } = rec; + + const { logoImageUrl } = owners[0]; + + return ( + + + + + + {partner.map((orgName, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {orgName} + + ))} + + + + ); +}; + +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, + })), + }).isRequired, +}; + +export default RecommendationCard; 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 1da521c..d10ac5e 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 3879fda..ff0920b 100644 --- a/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx +++ b/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx @@ -3,11 +3,13 @@ import { Stack, Row, Alert, Spinner, } from '@edx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { CheckCircle } from '@edx/paragon/icons'; +import { CheckCircle, ErrorOutline } from '@edx/paragon/icons'; import { SkillsBuilderContext } from '../../skills-builder-context'; import RelatedSkillsSelectableBoxSet from './RelatedSkillsSelectableBoxSet'; import { searchJobs, getProductRecommendations } from '../../utils/search'; import messages from './messages'; +import { productTypes } from './data/constants'; +import CarouselStack from './CarouselStack'; const ViewResults = () => { const { formatMessage } = useIntl(); @@ -17,12 +19,13 @@ const ViewResults = () => { const [selectedJobTitle, setSelectedJobTitle] = useState(''); const [jobSkillsList, setJobSkillsList] = useState([]); - // eslint-disable-next-line no-unused-vars - const [courseRecommendations, setCourseRecommendations] = useState([]); + const [productRecommendations, setProductRecommendations] = useState([]); + const [selectedRecommendations, setSelectedRecommendations] = useState({}); const [isLoading, setIsLoading] = useState(true); + const [fetchError, setFetchError] = useState(false); useEffect(() => { - const getJobs = async () => { + const getRecommendations = async () => { // fetch list of jobs with related skills const jobInfo = await searchJobs(jobSearchIndex, careerInterests); @@ -30,25 +33,56 @@ const ViewResults = () => { const results = await Promise.all(jobInfo.map(async (job) => { const formattedSkills = job.skills.map(skill => skill.name); - const recommendations = await getProductRecommendations(productSearchIndex, 'course', formattedSkills); - + // create a data object for each job const data = { id: job.id, name: job.name, - recommendations, + 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(jobInfo[0].name); - setCourseRecommendations(results); + setProductRecommendations(results); setIsLoading(false); }; - getJobs(); + getRecommendations() + .catch(() => { + setFetchError(true); + setIsLoading(false); + }); }, [careerInterests, jobSearchIndex, productSearchIndex]); + useEffect(() => { + setSelectedRecommendations(productRecommendations.find(rec => rec.name === selectedJobTitle)); + }, [productRecommendations, selectedJobTitle]); + + if (fetchError) { + return ( + + + {formatMessage(messages.matchesNotFoundDangerAlert)} + + + ); + } + return ( isLoading ? ( @@ -59,7 +93,7 @@ const ViewResults = () => { /> ) : ( - + { selectedJobTitle={selectedJobTitle} onChange={(e) => setSelectedJobTitle(e.target.value)} /> + + ) ); 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 new file mode 100644 index 0000000..c09761c --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/data/constants.js @@ -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, +]; diff --git a/src/skills-builder/skills-builder-modal/view-results/messages.js b/src/skills-builder/skills-builder-modal/view-results/messages.js index 0fc53ce..91660d6 100644 --- a/src/skills-builder/skills-builder-modal/view-results/messages.js +++ b/src/skills-builder/skills-builder-modal/view-results/messages.js @@ -6,6 +6,11 @@ const messages = defineMessages({ 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', @@ -16,6 +21,11 @@ const messages = defineMessages({ 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; diff --git a/src/skills-builder/skills-builder-modal/view-results/test/ViewResults.test.jsx b/src/skills-builder/skills-builder-modal/view-results/test/ViewResults.test.jsx new file mode 100644 index 0000000..8907c08 --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/test/ViewResults.test.jsx @@ -0,0 +1,79 @@ +import { + screen, render, cleanup, fireEvent, act, +} from '@testing-library/react'; +import { mergeConfig } from '@edx/frontend-platform'; +import { SkillsBuilderWrapperWithContext, contextValue } from '../../../test/setupSkillsBuilder'; +import { getProductRecommendations } from '../../../utils/search'; + +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' })); + }); + }); + + it('should render a for each career interest the learner has submitted', async () => { + 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'); + }); + + it('renders a carousel of components', async () => { + 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(); + }); + }); + + 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(); + }); + }); +}); diff --git a/src/skills-builder/test/SkillsBuilder.test.jsx b/src/skills-builder/test/SkillsBuilder.test.jsx index 30ddd4f..6803376 100644 --- a/src/skills-builder/test/SkillsBuilder.test.jsx +++ b/src/skills-builder/test/SkillsBuilder.test.jsx @@ -1,66 +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'; -import { mockData } from './__mocks__/jobSkills.mockData'; -import { getProductRecommendations, searchJobs, useAlgoliaSearch } from '../utils/search'; - -const dispatchMock = jest.fn(); - -jest.mock('@edx/frontend-platform/logging'); - -jest.mock('react-instantsearch-hooks-web', () => ({ - // eslint-disable-next-line react/prop-types - InstantSearch: ({ children }) => (
{children}
), - 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); - -const contextValue = { - state: { - ...skillsInitialState, - }, - dispatch: dispatchMock, - algolia: { - // Without this, tests would fail to destructure `searchClient` in the component - searchClient: {}, - productSearchIndex: {}, - jobSearchIndex: {}, - }, -}; - -const SkillsBuilderWrapperWithContext = (value) => ( - - - - - -); +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( @@ -74,120 +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', - }, - }, - ), - ); - const expectedGoal = { - payload: 'I want to advance my career', - type: 'SET_GOAL', - }; - const expectedJobTitle = { - payload: 'Student', - 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); - - expect(screen.getByText('Next, search and select your current job title')).toBeTruthy(); - expect(dispatchMock).toHaveBeenCalledWith(expectedGoal); - expect(dispatchMock).toHaveBeenCalledWith(expectedJobTitle); - }); - - 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 for each career interest', () => { - render( - SkillsBuilderWrapperWithContext( - { - ...contextValue, - state: { - ...contextValue.state, - currentGoal: 'I want to start my career', - currentJobTitle: 'Goblin Lackey', - careerInterests: ['Prospector', 'Mirror Breaker', 'Bombardment'], - }, - }, - ), - ); - expect(screen.getByText('Prospector')).toBeTruthy(); - expect(screen.getByText('Mirror Breaker')).toBeTruthy(); - expect(screen.getByText('Bombardment')).toBeTruthy(); - }); - - it('should remove a 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); - }); - - it('should render a for each career interest the learner has submitted', async () => { - render( - SkillsBuilderWrapperWithContext( - { - ...contextValue, - state: { - ...contextValue.state, - currentGoal: 'I want to start my career', - currentJobTitle: 'Goblin Lackey', - careerInterests: ['Prospector'], - }, - }, - ), - ); - await act(async () => { - fireEvent.click(screen.getByRole('button', { name: 'Next Step' })); - }); - - const chipComponents = document.querySelectorAll('.pgn__chip'); - expect(chipComponents[0].textContent).toEqual('finding shiny things'); - expect(chipComponents[1].textContent).toEqual('mining'); - - expect(screen.getByText('Prospector')).toBeTruthy(); - }); }); diff --git a/src/skills-builder/test/__mocks__/jobSkills.mockData.js b/src/skills-builder/test/__mocks__/jobSkills.mockData.js index 5150dec..e11bb5e 100644 --- a/src/skills-builder/test/__mocks__/jobSkills.mockData.js +++ b/src/skills-builder/test/__mocks__/jobSkills.mockData.js @@ -14,11 +14,24 @@ export const mockData = { id: 0, name: 'Prospector', skills: [ - { id: 0, + { 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, }, - { id: 1, + { external_id: 1, name: 'finding shiny things', significance: 100, }], @@ -26,14 +39,28 @@ export const mockData = { ], productRecommendations: [ { - id: 0, - name: 'Prospector', - recommendations: [{ name: 'Mining with the Mons' }, { name: 'The Art of Warren Upkeep' }], + title: 'Mining with the Mons', + uuid: 'thisIsARandomString01', + partner: ['edx'], + card_image_url: 'https://thisIsAUrl.ForAnImage.01.jpeg', + marketing_url: 'https://thisIsAUrl.ForTheRecommendedContent.01.com', + owners: [ + { + logoImageUrl: 'https://thisIsAUrl.ForALogoImage.01.jpeg', + } + ] }, { - id: 1, - name: 'Mirror Breaker', - recommendations: [{ name: 'Mirror Breaking 101' }], + 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', + owners: [ + { + logoImageUrl: 'https://thisIsAUrl.ForALogoImage.02.jpeg', + } + ] }, ], useAlgoliaSearch: [{}, {}, {}], diff --git a/src/skills-builder/test/setupSkillsBuilder.jsx b/src/skills-builder/test/setupSkillsBuilder.jsx new file mode 100644 index 0000000..fb130af --- /dev/null +++ b/src/skills-builder/test/setupSkillsBuilder.jsx @@ -0,0 +1,49 @@ +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 }) => (
{children}
), + 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 component + searchClient: {}, + productSearchIndex: {}, + jobSearchIndex: {}, + }, +}; + +export const SkillsBuilderWrapperWithContext = (value = contextValue) => ( + + + + + +); diff --git a/src/skills-builder/utils/search.jsx b/src/skills-builder/utils/search.jsx index 0e4d0ac..ffc9234 100644 --- a/src/skills-builder/utils/search.jsx +++ b/src/skills-builder/utils/search.jsx @@ -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}"`, facetFilters: [ formattedSkillNames, ], diff --git a/src/skills-builder/utils/tests/search.test.jsx b/src/skills-builder/utils/tests/search.test.jsx index 6fa20ca..f879d73 100644 --- a/src/skills-builder/utils/tests/search.test.jsx +++ b/src/skills-builder/utils/tests/search.test.jsx @@ -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"', facetFilters: [ ['skills.skill:Sword Lobbing'], ],