From b1fe21cdedf2caaa0b629bc7e07451b99292a947 Mon Sep 17 00:00:00 2001 From: Maxwell Frank Date: Mon, 27 Feb 2023 20:03:25 +0000 Subject: [PATCH] feat: SkillsBuilder jobs with related skills --- .../SkillsBuilderModal.jsx | 18 ++-- .../select-preferences/CareerInterestCard.jsx | 4 +- .../CareerInterestSelect.jsx | 10 ++- .../select-preferences/GoalSelect.jsx | 20 +++-- .../JobTitleInstantSearch.jsx | 7 ++ .../select-preferences/JobTitleSelect.jsx | 23 +++-- .../select-preferences/SelectPreferences.jsx | 11 +-- .../select-preferences/messages.js | 5 ++ .../RelatedSkillsSelectableBoxSet.jsx | 57 +++++++++++++ .../view-results/ViewResults.jsx | 83 ++++++++++++++++++- .../view-results/messages.js | 21 +++++ .../test/SkillsBuilder.test.jsx | 47 ++++++++++- .../test/__mocks__/jobSkills.mockData.js | 40 +++++++++ 13 files changed, 302 insertions(+), 44 deletions(-) create mode 100644 src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx create mode 100644 src/skills-builder/skills-builder-modal/view-results/messages.js create mode 100644 src/skills-builder/test/__mocks__/jobSkills.mockData.js diff --git a/src/skills-builder/skills-builder-modal/SkillsBuilderModal.jsx b/src/skills-builder/skills-builder-modal/SkillsBuilderModal.jsx index fb7df32..3c08231 100644 --- a/src/skills-builder/skills-builder-modal/SkillsBuilderModal.jsx +++ b/src/skills-builder/skills-builder-modal/SkillsBuilderModal.jsx @@ -2,7 +2,7 @@ import React, { useState, useContext } from 'react'; import { Button, Container, Stepper, ModalDialog, } from '@edx/paragon'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { useHistory } from 'react-router'; import { STEP1, STEP2, @@ -17,7 +17,7 @@ import ViewResults from './view-results/ViewResults'; import headerImage from '../images/headerImage.png'; const SkillsBuilderModal = () => { - const intl = useIntl(); + const { formatMessage } = useIntl(); const { state } = useContext(SkillsBuilderContext); const { careerInterests } = state; const [currentStep, setCurrentStep] = useState(STEP1); @@ -49,12 +49,12 @@ const SkillsBuilderModal = () => { - - + + - + @@ -63,14 +63,14 @@ const SkillsBuilderModal = () => { @@ -78,11 +78,11 @@ const SkillsBuilderModal = () => { variant="outline-primary" onClick={() => setCurrentStep(STEP1)} > - + {formatMessage(messages.goBackButton)} diff --git a/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestCard.jsx b/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestCard.jsx index 62b7a37..a0d250b 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestCard.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestCard.jsx @@ -10,7 +10,7 @@ import { removeCareerInterest } from '../../data/actions'; import messages from './messages'; const CareerInterestCard = ({ interest }) => { - const intl = useIntl(); + const { formatMessage } = useIntl(); const { dispatch } = useContext(SkillsBuilderContext); return ( @@ -21,7 +21,7 @@ const CareerInterestCard = ({ interest }) => { dispatch(removeCareerInterest(interest))} /> 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 892fb37..74fbd1f 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestSelect.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/CareerInterestSelect.jsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import { getConfig } from '@edx/frontend-platform'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Stack, Row, Col } from '@edx/paragon'; import { InstantSearch } from 'react-instantsearch-hooks-web'; import JobTitleInstantSearch from './JobTitleInstantSearch'; @@ -10,6 +10,7 @@ import { SkillsBuilderContext } from '../../skills-builder-context'; import messages from './messages'; const CareerInterestSelect = () => { + const { formatMessage } = useIntl(); const { state, dispatch, algolia } = useContext(SkillsBuilderContext); const { careerInterests } = state; const { searchClient } = algolia; @@ -26,10 +27,13 @@ const CareerInterestSelect = () => { return (

- + {formatMessage(messages.careerInterestPrompt)}

- + {careerInterests.map((interest, index) => ( 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 30b1dc0..77f5fb0 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/GoalSelect.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/GoalSelect.jsx @@ -3,19 +3,21 @@ import { Form, Stack, } from '@edx/paragon'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { setGoal } from '../../data/actions'; import { SkillsBuilderContext } from '../../skills-builder-context'; import messages from './messages'; const GoalDropdown = () => { - const intl = useIntl(); + const { formatMessage } = useIntl(); const { state, dispatch } = useContext(SkillsBuilderContext); const { currentGoal } = state; return ( -

+

+ {formatMessage(messages.learningGoalPrompt)} +

{ onChange={(e) => 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 5b60665..5c29d7b 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/JobTitleInstantSearch.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/JobTitleInstantSearch.jsx @@ -25,6 +25,8 @@ const JobTitleInstantSearch = (props) => { onChange={handleAutosuggestChange} name="job-title-suggest" onSelected={props.onSelected} + autoComplete="off" + placeholder={props.placeholder} > {hits.map(job => ( @@ -35,8 +37,13 @@ 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 3f625f6..35552ff 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/JobTitleSelect.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/JobTitleSelect.jsx @@ -3,7 +3,7 @@ import { getConfig } from '@edx/frontend-platform'; import { Form, Stack, } from '@edx/paragon'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { InstantSearch } from 'react-instantsearch-hooks-web'; import { setCurrentJobTitle } from '../../data/actions'; import { SkillsBuilderContext } from '../../skills-builder-context'; @@ -11,35 +11,40 @@ import JobTitleInstantSearch from './JobTitleInstantSearch'; import messages from './messages'; const JobTitleSelect = () => { - const { dispatch, algolia } = useContext(SkillsBuilderContext); + const { formatMessage } = useIntl(); + const { state, dispatch, algolia } = useContext(SkillsBuilderContext); const { searchClient } = algolia; + const { currentJobTitle } = state; const handleCurrentJobTitleSelect = (value) => { dispatch(setCurrentJobTitle(value)); }; - // Below implementation sets the job title to "student" or "looking_for_work" — this overwrites any previous selection + // Below implementation sets the job title to "Student" or "Looking for work" — this overwrites any previous selection // This will need to be revisited when we decide what to do with this data const handleCheckboxChange = (e) => dispatch(setCurrentJobTitle(e.target.value)); return (

- + {formatMessage(messages.jobTitlePrompt)}

- + - - + + {formatMessage(messages.studentCheckboxPrompt)} - - + + {formatMessage(messages.currentlyLookingCheckboxPrompt)} diff --git a/src/skills-builder/skills-builder-modal/select-preferences/SelectPreferences.jsx b/src/skills-builder/skills-builder-modal/select-preferences/SelectPreferences.jsx index 7eee9a6..90f65a5 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/SelectPreferences.jsx +++ b/src/skills-builder/skills-builder-modal/select-preferences/SelectPreferences.jsx @@ -2,7 +2,7 @@ import React, { useContext } from 'react'; import { Stack, } from '@edx/paragon'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { SkillsBuilderContext } from '../../skills-builder-context'; import GoalSelect from './GoalSelect'; import JobTitleSelect from './JobTitleSelect'; @@ -10,13 +10,14 @@ import CareerInterestSelect from './CareerInterestSelect'; import messages from './messages'; const SelectPreferences = () => { + const { formatMessage } = useIntl(); const { state } = useContext(SkillsBuilderContext); const { currentGoal, currentJobTitle } = state; return ( - <> -

- + +

+ {formatMessage(messages.skillsBuilderDescription)}

@@ -30,7 +31,7 @@ const SelectPreferences = () => { )} - +
); }; diff --git a/src/skills-builder/skills-builder-modal/select-preferences/messages.js b/src/skills-builder/skills-builder-modal/select-preferences/messages.js index 53e1551..5efbb80 100644 --- a/src/skills-builder/skills-builder-modal/select-preferences/messages.js +++ b/src/skills-builder/skills-builder-modal/select-preferences/messages.js @@ -61,6 +61,11 @@ const messages = defineMessages({ defaultMessage: 'What careers are you interested in?', description: 'Prompts the user to select careers they are interested in pursuing.', }, + careerInterestInputPlaceholder: { + id: 'career.interest.input.placeholder', + defaultMessage: 'Select up to 3 new job titles', + description: 'Placeholder text for the career interest input control.', + }, removeCareerInterestButtonAltText: { id: 'career.interest.remove.button.alt.text', defaultMessage: 'Remove career interest: ', diff --git a/src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx b/src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx new file mode 100644 index 0000000..1da521c --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/RelatedSkillsSelectableBoxSet.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + SelectableBox, Chip, Stack, useMediaQuery, breakpoints, +} from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +const RelatedSkillsSelectableBoxSet = ({ jobSkillsList, selectedJobTitle, onChange }) => { + const { formatMessage } = useIntl(); + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + const renderTopFiveSkills = (skills) => { + const topFiveSkills = skills.sort((a, b) => b.significance - a.significance).slice(0, 5); + return ( + topFiveSkills.map(skill => ( + + {skill.name} + + )) + ); + }; + + return ( + + {jobSkillsList.map(job => ( + +

{job.name}

+ +

{formatMessage(messages.relatedSkillsHeading)}

+ {renderTopFiveSkills(job.skills)} +
+
+ ))} +
+ ); +}; + +RelatedSkillsSelectableBoxSet.propTypes = { + jobSkillsList: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + selectedJobTitle: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default RelatedSkillsSelectableBoxSet; 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 3477aeb..3879fda 100644 --- a/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx +++ b/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx @@ -1,7 +1,82 @@ -import React from 'react'; +import React, { useContext, useEffect, useState } from 'react'; +import { + Stack, Row, Alert, Spinner, +} from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { CheckCircle } from '@edx/paragon/icons'; +import { SkillsBuilderContext } from '../../skills-builder-context'; +import RelatedSkillsSelectableBoxSet from './RelatedSkillsSelectableBoxSet'; +import { searchJobs, getProductRecommendations } from '../../utils/search'; +import messages from './messages'; -const ViewResults = () => ( -

Results will render on this step

-); +const ViewResults = () => { + const { formatMessage } = useIntl(); + const { algolia, state } = useContext(SkillsBuilderContext); + const { jobSearchIndex, productSearchIndex } = algolia; + const { careerInterests } = state; + + const [selectedJobTitle, setSelectedJobTitle] = useState(''); + const [jobSkillsList, setJobSkillsList] = useState([]); + // eslint-disable-next-line no-unused-vars + const [courseRecommendations, setCourseRecommendations] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const getJobs = 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); + + const recommendations = await getProductRecommendations(productSearchIndex, 'course', formattedSkills); + + const data = { + id: job.id, + name: job.name, + recommendations, + }; + + return data; + })); + + setJobSkillsList(jobInfo); + setSelectedJobTitle(jobInfo[0].name); + setCourseRecommendations(results); + setIsLoading(false); + }; + getJobs(); + }, [careerInterests, jobSearchIndex, productSearchIndex]); + + return ( + isLoading ? ( + + + + ) : ( + + + + {formatMessage(messages.matchesFoundSuccessAlert)} + + + + setSelectedJobTitle(e.target.value)} + /> + + ) + ); +}; export default ViewResults; diff --git a/src/skills-builder/skills-builder-modal/view-results/messages.js b/src/skills-builder/skills-builder-modal/view-results/messages.js new file mode 100644 index 0000000..0fc53ce --- /dev/null +++ b/src/skills-builder/skills-builder-modal/view-results/messages.js @@ -0,0 +1,21 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + matchesFoundSuccessAlert: { + id: 'matches.found.success.alert', + defaultMessage: 'We found skills and courses that match your preferences!', + description: 'Success alert message to display when recommendations are presented to the learner.', + }, + relatedSkillsHeading: { + id: 'related.skills.heading', + defaultMessage: 'Related Skills', + description: 'Heading text for a selectable box that displays related skills for a corresponding selected job title.', + }, + relatedSkillsSelectableBoxLabelText: { + id: 'related.skills.selectable.box.label.text', + defaultMessage: 'Related skills:', + description: 'Label text for a selectable box that displays related skills for a corresponding selected job title.', + }, +}); + +export default messages; diff --git a/src/skills-builder/test/SkillsBuilder.test.jsx b/src/skills-builder/test/SkillsBuilder.test.jsx index b9b8b7b..30ddd4f 100644 --- a/src/skills-builder/test/SkillsBuilder.test.jsx +++ b/src/skills-builder/test/SkillsBuilder.test.jsx @@ -8,15 +8,29 @@ 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: [{ name: 'Text File Engineer' }, { name: 'Screen Viewer' }] })), + useHits: jest.fn(() => ({ hits: mockData.hits })), })); -const dispatchMock = jest.fn(); +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: { @@ -26,6 +40,8 @@ const contextValue = { algolia: { // Without this, tests would fail to destructure `searchClient` in the component searchClient: {}, + productSearchIndex: {}, + jobSearchIndex: {}, }, }; @@ -76,7 +92,7 @@ describe('skills-builder', () => { type: 'SET_GOAL', }; const expectedJobTitle = { - payload: 'student', + payload: 'Student', type: 'SET_CURRENT_JOB_TITLE', }; @@ -149,4 +165,29 @@ describe('skills-builder', () => { 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 new file mode 100644 index 0000000..5150dec --- /dev/null +++ b/src/skills-builder/test/__mocks__/jobSkills.mockData.js @@ -0,0 +1,40 @@ +export const mockData = { + hits: [ + { + id: 0, + name: 'Text File Engineer' + }, + { + id: 1, + name: 'Screen Viewer' + }, + ], + searchJobs: [ + { + id: 0, + name: 'Prospector', + skills: [ + { id: 0, + name: 'mining', + significance: 50, + }, + { id: 1, + name: 'finding shiny things', + significance: 100, + }], + }, + ], + productRecommendations: [ + { + id: 0, + name: 'Prospector', + recommendations: [{ name: 'Mining with the Mons' }, { name: 'The Art of Warren Upkeep' }], + }, + { + id: 1, + name: 'Mirror Breaker', + recommendations: [{ name: 'Mirror Breaking 101' }], + }, + ], + useAlgoliaSearch: [{}, {}, {}], +};