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 0000000..1b03102 Binary files /dev/null and b/src/skills-builder/images/card-imagecap-fallback.png differ 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'], ],