Merge pull request #706 from openedx/mfrank/retrieving-job-skills-data

APER-2187 Render jobs and related skills for Skills Builder
This commit is contained in:
Maxwell Frank
2023-03-08 14:03:46 -05:00
committed by GitHub
13 changed files with 302 additions and 44 deletions

View File

@@ -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 = () => {
<Stepper.Header />
<ModalDialog.Body>
<Container size="md">
<Stepper.Step eventKey={STEP1} title={intl.formatMessage(messages.selectPreferences)}>
<Container size="md" className="p-4.5">
<Stepper.Step eventKey={STEP1} title={formatMessage(messages.selectPreferences)}>
<SelectPreferences />
</Stepper.Step>
<Stepper.Step eventKey={STEP2} title={intl.formatMessage(messages.reviewResults)}>
<Stepper.Step eventKey={STEP2} title={formatMessage(messages.reviewResults)}>
<ViewResults />
</Stepper.Step>
</Container>
@@ -63,14 +63,14 @@ const SkillsBuilderModal = () => {
<ModalDialog.Footer>
<Stepper.ActionRow eventKey={STEP1}>
<Button variant="outline-primary" onClick={onCloseHandle}>
<FormattedMessage {...messages.goBackButton} />
{formatMessage(messages.goBackButton)}
</Button>
<Stepper.ActionRow.Spacer />
<Button
onClick={() => setCurrentStep(STEP2)}
disabled={careerInterests.length === 0}
>
<FormattedMessage {...messages.nextStepButton} />
{formatMessage(messages.nextStepButton)}
</Button>
</Stepper.ActionRow>
<Stepper.ActionRow eventKey={STEP2}>
@@ -78,11 +78,11 @@ const SkillsBuilderModal = () => {
variant="outline-primary"
onClick={() => setCurrentStep(STEP1)}
>
<FormattedMessage {...messages.goBackButton} />
{formatMessage(messages.goBackButton)}
</Button>
<Stepper.ActionRow.Spacer />
<Button onClick={onCloseHandle}>
<FormattedMessage {...messages.exitButton} />
{formatMessage(messages.exitButton)}
</Button>
</Stepper.ActionRow>
</ModalDialog.Footer>

View File

@@ -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 }) => {
<IconButton
iconAs={Icon}
src={Close}
alt={`${intl.formatMessage(messages.removeCareerInterestButtonAltText)} ${interest}`}
alt={`${formatMessage(messages.removeCareerInterestButtonAltText)} ${interest}`}
onClick={() => dispatch(removeCareerInterest(interest))}
/>
</div>

View File

@@ -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 (
<Stack gap={2}>
<h4>
<FormattedMessage {...messages.careerInterestPrompt} />
{formatMessage(messages.careerInterestPrompt)}
</h4>
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
<JobTitleInstantSearch onSelected={handleCareerInterestSelect} />
<JobTitleInstantSearch
onSelected={handleCareerInterestSelect}
placeholder={formatMessage(messages.careerInterestInputPlaceholder)}
/>
</InstantSearch>
<Row>
{careerInterests.map((interest, index) => (

View File

@@ -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 (
<Stack gap={2}>
<h4><FormattedMessage {...messages.learningGoalPrompt} /></h4>
<h4>
{formatMessage(messages.learningGoalPrompt)}
</h4>
<Form.Group>
<Form.Control
as="select"
@@ -23,12 +25,12 @@ const GoalDropdown = () => {
onChange={(e) => dispatch(setGoal(e.target.value))}
data-testid="goal-select-dropdown"
>
<option value="">{intl.formatMessage(messages.selectLearningGoal)}</option>
<option>{intl.formatMessage(messages.learningGoalStartCareer)}</option>
<option>{intl.formatMessage(messages.learningGoalAdvanceCareer)}</option>
<option>{intl.formatMessage(messages.learningGoalChangeCareer)}</option>
<option>{intl.formatMessage(messages.learningGoalSomethingNew)}</option>
<option>{intl.formatMessage(messages.learningGoalSomethingElse)}</option>
<option value="">{formatMessage(messages.selectLearningGoal)}</option>
<option>{formatMessage(messages.learningGoalStartCareer)}</option>
<option>{formatMessage(messages.learningGoalAdvanceCareer)}</option>
<option>{formatMessage(messages.learningGoalChangeCareer)}</option>
<option>{formatMessage(messages.learningGoalSomethingNew)}</option>
<option>{formatMessage(messages.learningGoalSomethingElse)}</option>
</Form.Control>
</Form.Group>
</Stack>

View File

@@ -25,6 +25,8 @@ const JobTitleInstantSearch = (props) => {
onChange={handleAutosuggestChange}
name="job-title-suggest"
onSelected={props.onSelected}
autoComplete="off"
placeholder={props.placeholder}
>
{hits.map(job => (
<Form.AutosuggestOption key={job.id}>
@@ -35,8 +37,13 @@ const JobTitleInstantSearch = (props) => {
);
};
JobTitleInstantSearch.defaultProps = {
placeholder: '',
};
JobTitleInstantSearch.propTypes = {
onSelected: PropTypes.func.isRequired,
placeholder: PropTypes.string,
};
export default JobTitleInstantSearch;

View File

@@ -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 (
<Stack gap={2}>
<h4>
<FormattedMessage {...messages.jobTitlePrompt} />
{formatMessage(messages.jobTitlePrompt)}
</h4>
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
<JobTitleInstantSearch onSelected={handleCurrentJobTitleSelect} />
<JobTitleInstantSearch
onSelected={handleCurrentJobTitleSelect}
placeholder={currentJobTitle}
/>
</InstantSearch>
<Form.Group>
<Form.CheckboxSet
name="other-occupations"
onChange={handleCheckboxChange}
>
<Form.Checkbox value="student">
<FormattedMessage {...messages.studentCheckboxPrompt} />
<Form.Checkbox value="Student">
{formatMessage(messages.studentCheckboxPrompt)}
</Form.Checkbox>
<Form.Checkbox value="looking_for_work">
<FormattedMessage {...messages.currentlyLookingCheckboxPrompt} />
<Form.Checkbox value="Looking for work">
{formatMessage(messages.currentlyLookingCheckboxPrompt)}
</Form.Checkbox>
</Form.CheckboxSet>
</Form.Group>

View File

@@ -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 (
<>
<p className="lead mb-5">
<FormattedMessage {...messages.skillsBuilderDescription} />
<Stack gap={4}>
<p className="lead">
{formatMessage(messages.skillsBuilderDescription)}
</p>
<Stack gap={4}>
@@ -30,7 +31,7 @@ const SelectPreferences = () => {
<CareerInterestSelect />
)}
</Stack>
</>
</Stack>
);
};

View File

@@ -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: ',

View File

@@ -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 => (
<Chip key={skill.id}>
{skill.name}
</Chip>
))
);
};
return (
<SelectableBox.Set
name="selected job title"
type="radio"
value={selectedJobTitle}
onChange={onChange}
columns={isExtraSmall ? 1 : 3}
>
{jobSkillsList.map(job => (
<SelectableBox
key={job.id}
type="radio"
value={job.name}
aria-label={job.name}
inputHidden={false}
>
<p>{job.name}</p>
<Stack gap={2} className="align-items-start">
<p className="heading-label x-small">{formatMessage(messages.relatedSkillsHeading)}</p>
{renderTopFiveSkills(job.skills)}
</Stack>
</SelectableBox>
))}
</SelectableBox.Set>
);
};
RelatedSkillsSelectableBoxSet.propTypes = {
jobSkillsList: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
selectedJobTitle: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
};
export default RelatedSkillsSelectableBoxSet;

View File

@@ -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 = () => (
<h3>Results will render on this step</h3>
);
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 ? (
<Row>
<Spinner
animation="border"
screenReaderText="loading"
className="mx-auto"
/>
</Row>
) : (
<Stack gap={4.5}>
<Alert
variant="success"
icon={CheckCircle}
>
<Alert.Heading>
{formatMessage(messages.matchesFoundSuccessAlert)}
</Alert.Heading>
</Alert>
<RelatedSkillsSelectableBoxSet
jobSkillsList={jobSkillsList}
selectedJobTitle={selectedJobTitle}
onChange={(e) => setSelectedJobTitle(e.target.value)}
/>
</Stack>
)
);
};
export default ViewResults;

View File

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

View File

@@ -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 }) => (<div>{children}</div>),
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 <JobTitleSelect> 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 <JobSillsSelectableBox> 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();
});
});

View File

@@ -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: [{}, {}, {}],
};