Revert "feat: course recommendations for Skills Builder"
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Stack, Row, Col, Form,
|
||||
} from '@edx/paragon';
|
||||
import { Stack, Row, Col } from '@edx/paragon';
|
||||
import { InstantSearch } from 'react-instantsearch-hooks-web';
|
||||
import JobTitleInstantSearch from './JobTitleInstantSearch';
|
||||
import CareerInterestCard from './CareerInterestCard';
|
||||
@@ -18,24 +16,25 @@ const CareerInterestSelect = () => {
|
||||
const { searchClient } = algolia;
|
||||
|
||||
const handleCareerInterestSelect = (value) => {
|
||||
if (!careerInterests.includes(value) && careerInterests.length < 3) {
|
||||
// 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) {
|
||||
dispatch(addCareerInterest(value));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Form.Label>
|
||||
<h4 className="mb-3">
|
||||
{formatMessage(messages.careerInterestPrompt)}
|
||||
</h4>
|
||||
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
|
||||
<JobTitleInstantSearch
|
||||
onSelected={handleCareerInterestSelect}
|
||||
placeholder={formatMessage(messages.careerInterestInputPlaceholderText)}
|
||||
/>
|
||||
</InstantSearch>
|
||||
</Form.Label>
|
||||
<h4>
|
||||
{formatMessage(messages.careerInterestPrompt)}
|
||||
</h4>
|
||||
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
|
||||
<JobTitleInstantSearch
|
||||
onSelected={handleCareerInterestSelect}
|
||||
placeholder={formatMessage(messages.careerInterestInputPlaceholder)}
|
||||
/>
|
||||
</InstantSearch>
|
||||
<Row>
|
||||
{careerInterests.map((interest, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Stack,
|
||||
} from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { setGoal } from '../../data/actions';
|
||||
@@ -13,26 +14,27 @@ const GoalDropdown = () => {
|
||||
const { currentGoal } = state;
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
<h4>
|
||||
{formatMessage(messages.learningGoalPrompt)}
|
||||
</h4>
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={currentGoal}
|
||||
onChange={(e) => dispatch(setGoal(e.target.value))}
|
||||
data-testid="goal-select-dropdown"
|
||||
>
|
||||
<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 gap={2}>
|
||||
<h4>
|
||||
{formatMessage(messages.learningGoalPrompt)}
|
||||
</h4>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={currentGoal}
|
||||
onChange={(e) => dispatch(setGoal(e.target.value))}
|
||||
data-testid="goal-select-dropdown"
|
||||
>
|
||||
<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>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -24,11 +24,12 @@ const JobTitleInstantSearch = (props) => {
|
||||
value={jobInput}
|
||||
onChange={handleAutosuggestChange}
|
||||
name="job-title-suggest"
|
||||
onSelected={props.onSelected}
|
||||
autoComplete="off"
|
||||
{...props}
|
||||
placeholder={props.placeholder}
|
||||
>
|
||||
{hits.map(job => (
|
||||
<Form.AutosuggestOption key={job.id} id={job.name.replaceAll(' ', '-').toLowerCase()}>
|
||||
<Form.AutosuggestOption key={job.id}>
|
||||
{job.name}
|
||||
</Form.AutosuggestOption>
|
||||
))}
|
||||
@@ -36,8 +37,13 @@ const JobTitleInstantSearch = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
JobTitleInstantSearch.defaultProps = {
|
||||
placeholder: '',
|
||||
};
|
||||
|
||||
JobTitleInstantSearch.propTypes = {
|
||||
onSelected: PropTypes.func.isRequired,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
||||
export default JobTitleInstantSearch;
|
||||
|
||||
@@ -12,8 +12,9 @@ import messages from './messages';
|
||||
|
||||
const JobTitleSelect = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { dispatch, algolia } = useContext(SkillsBuilderContext);
|
||||
const { state, dispatch, algolia } = useContext(SkillsBuilderContext);
|
||||
const { searchClient } = algolia;
|
||||
const { currentJobTitle } = state;
|
||||
|
||||
const handleCurrentJobTitleSelect = (value) => {
|
||||
dispatch(setCurrentJobTitle(value));
|
||||
@@ -24,18 +25,16 @@ const JobTitleSelect = () => {
|
||||
const handleCheckboxChange = (e) => dispatch(setCurrentJobTitle(e.target.value));
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Form.Label>
|
||||
<h4 className="mb-3">
|
||||
{formatMessage(messages.jobTitlePrompt)}
|
||||
</h4>
|
||||
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
|
||||
<JobTitleInstantSearch
|
||||
onSelected={handleCurrentJobTitleSelect}
|
||||
placeholder={formatMessage(messages.jobTitleInputPlaceholderText)}
|
||||
/>
|
||||
</InstantSearch>
|
||||
</Form.Label>
|
||||
<Stack gap={2}>
|
||||
<h4>
|
||||
{formatMessage(messages.jobTitlePrompt)}
|
||||
</h4>
|
||||
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
|
||||
<JobTitleInstantSearch
|
||||
onSelected={handleCurrentJobTitleSelect}
|
||||
placeholder={currentJobTitle}
|
||||
/>
|
||||
</InstantSearch>
|
||||
<Form.Group>
|
||||
<Form.CheckboxSet
|
||||
name="other-occupations"
|
||||
|
||||
@@ -46,11 +46,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Next, search and select your current job title',
|
||||
description: 'Prompts the user to select their current job title or occupation.',
|
||||
},
|
||||
jobTitleInputPlaceholderText: {
|
||||
id: 'job.title.input.placeholder.text',
|
||||
defaultMessage: 'Search and select a job title',
|
||||
description: 'Placeholder text for the job title input control.',
|
||||
},
|
||||
studentCheckboxPrompt: {
|
||||
id: 'student.checkbox.prompt',
|
||||
defaultMessage: 'I\'m a student',
|
||||
@@ -66,8 +61,8 @@ const messages = defineMessages({
|
||||
defaultMessage: 'What careers are you interested in?',
|
||||
description: 'Prompts the user to select careers they are interested in pursuing.',
|
||||
},
|
||||
careerInterestInputPlaceholderText: {
|
||||
id: 'career.interest.input.placeholder.text',
|
||||
careerInterestInputPlaceholder: {
|
||||
id: 'career.interest.input.placeholder',
|
||||
defaultMessage: 'Select up to 3 new job titles',
|
||||
description: 'Placeholder text for the career interest input control.',
|
||||
},
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import {
|
||||
screen, render, cleanup, fireEvent,
|
||||
} from '@testing-library/react';
|
||||
import { mergeConfig } from '@edx/frontend-platform';
|
||||
import { SkillsBuilderWrapperWithContext, dispatchMock, contextValue } from '../../../test/setupSkillsBuilder';
|
||||
|
||||
describe('select-preferences', () => {
|
||||
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 <CareerInterestCard> 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 <CareerInterestCard> 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
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) => (
|
||||
<h3>
|
||||
{formatMessage(messages.productRecommendationsHeaderText, {
|
||||
productType: normalizeProductTypeName(productType),
|
||||
jobName,
|
||||
})}
|
||||
</h3>
|
||||
);
|
||||
|
||||
return (
|
||||
productTypeNames.map(productType => (
|
||||
<CardCarousel
|
||||
key={productType}
|
||||
ariaLabel="card carousel"
|
||||
title={renderCarouselTitle(productType)}
|
||||
>
|
||||
{recommendations[productType].map(rec => (
|
||||
<RecommendationCard rec={rec} key={rec.uuid} />
|
||||
))}
|
||||
</CardCarousel>
|
||||
)));
|
||||
};
|
||||
|
||||
export default CarouselStack;
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, Chip, Hyperlink } from '@edx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import cardImageCapFallbackSrc from '@edx/brand/paragon/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 (
|
||||
<Hyperlink destination={marketingUrl} target="_blank" showLaunchIcon={false}>
|
||||
<Card className={classNames('carousel-card')}>
|
||||
<Card.ImageCap
|
||||
src={cardImageUrl}
|
||||
logoSrc={logoImageUrl}
|
||||
fallackSrc={cardImageCapFallbackSrc}
|
||||
fallBackLogo={cardImageCapFallbackSrc}
|
||||
/>
|
||||
<Card.Header title={title} />
|
||||
<Card.Section>
|
||||
{partner.map((orgName, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Chip key={index}>
|
||||
{orgName}
|
||||
</Chip>
|
||||
))}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Hyperlink>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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 => (
|
||||
<Chip key={skill.external_id}>
|
||||
<Chip key={skill.id}>
|
||||
{skill.name}
|
||||
</Chip>
|
||||
))
|
||||
|
||||
@@ -3,13 +3,11 @@ import {
|
||||
Stack, Row, Alert, Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { CheckCircle, ErrorOutline } from '@edx/paragon/icons';
|
||||
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';
|
||||
import { productTypes } from './data/constants';
|
||||
import CarouselStack from './CarouselStack';
|
||||
|
||||
const ViewResults = () => {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -19,13 +17,12 @@ const ViewResults = () => {
|
||||
|
||||
const [selectedJobTitle, setSelectedJobTitle] = useState('');
|
||||
const [jobSkillsList, setJobSkillsList] = useState([]);
|
||||
const [productRecommendations, setProductRecommendations] = useState([]);
|
||||
const [selectedRecommendations, setSelectedRecommendations] = useState({});
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [courseRecommendations, setCourseRecommendations] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const getRecommendations = async () => {
|
||||
const getJobs = async () => {
|
||||
// fetch list of jobs with related skills
|
||||
const jobInfo = await searchJobs(jobSearchIndex, careerInterests);
|
||||
|
||||
@@ -33,56 +30,25 @@ const ViewResults = () => {
|
||||
const results = await Promise.all(jobInfo.map(async (job) => {
|
||||
const formattedSkills = job.skills.map(skill => skill.name);
|
||||
|
||||
// create a data object for each job
|
||||
const recommendations = await getProductRecommendations(productSearchIndex, 'course', formattedSkills);
|
||||
|
||||
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);
|
||||
setProductRecommendations(results);
|
||||
setCourseRecommendations(results);
|
||||
setIsLoading(false);
|
||||
};
|
||||
getRecommendations()
|
||||
.catch(() => {
|
||||
setFetchError(true);
|
||||
setIsLoading(false);
|
||||
});
|
||||
getJobs();
|
||||
}, [careerInterests, jobSearchIndex, productSearchIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedRecommendations(productRecommendations.find(rec => rec.name === selectedJobTitle));
|
||||
}, [productRecommendations, selectedJobTitle]);
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<Alert
|
||||
variant="danger"
|
||||
icon={ErrorOutline}
|
||||
>
|
||||
<Alert.Heading>
|
||||
{formatMessage(messages.matchesNotFoundDangerAlert)}
|
||||
</Alert.Heading>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
isLoading ? (
|
||||
<Row>
|
||||
@@ -93,7 +59,7 @@ const ViewResults = () => {
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<Stack gap={4.5} className="pb-4.5">
|
||||
<Stack gap={4.5}>
|
||||
<Alert
|
||||
variant="success"
|
||||
icon={CheckCircle}
|
||||
@@ -108,8 +74,6 @@ const ViewResults = () => {
|
||||
selectedJobTitle={selectedJobTitle}
|
||||
onChange={(e) => setSelectedJobTitle(e.target.value)}
|
||||
/>
|
||||
|
||||
<CarouselStack selectedRecommendations={selectedRecommendations} />
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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,
|
||||
];
|
||||
@@ -6,11 +6,6 @@ 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',
|
||||
@@ -21,11 +16,6 @@ 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;
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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 <JobSillsSelectableBox> 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 <Card> 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,66 @@
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import React from 'react';
|
||||
import {
|
||||
screen, render, act,
|
||||
screen, render, cleanup, fireEvent, act,
|
||||
} from '@testing-library/react';
|
||||
import { mergeConfig } from '@edx/frontend-platform';
|
||||
import { SkillsBuilder } from '..';
|
||||
import { SkillsBuilderProvider } from '../skills-builder-context';
|
||||
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: 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 <JobTitleSelect> component
|
||||
searchClient: {},
|
||||
productSearchIndex: {},
|
||||
jobSearchIndex: {},
|
||||
},
|
||||
};
|
||||
|
||||
const SkillsBuilderWrapperWithContext = (value) => (
|
||||
<IntlProvider locale="en">
|
||||
<SkillsBuilderContext.Provider value={value}>
|
||||
<SkillsBuilderModal />
|
||||
</SkillsBuilderContext.Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -20,4 +74,120 @@ 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 <CareerInterestCard> 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 <CareerInterestCard> 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 <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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,24 +14,11 @@ export const mockData = {
|
||||
id: 0,
|
||||
name: 'Prospector',
|
||||
skills: [
|
||||
{ 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,
|
||||
{ id: 0,
|
||||
name: 'mining',
|
||||
significance: 50,
|
||||
},
|
||||
{ external_id: 1,
|
||||
{ id: 1,
|
||||
name: 'finding shiny things',
|
||||
significance: 100,
|
||||
}],
|
||||
@@ -39,28 +26,14 @@ export const mockData = {
|
||||
],
|
||||
productRecommendations: [
|
||||
{
|
||||
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: 0,
|
||||
name: 'Prospector',
|
||||
recommendations: [{ name: 'Mining with the Mons' }, { name: 'The Art of Warren Upkeep' }],
|
||||
},
|
||||
{
|
||||
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',
|
||||
}
|
||||
]
|
||||
id: 1,
|
||||
name: 'Mirror Breaker',
|
||||
recommendations: [{ name: 'Mirror Breaking 101' }],
|
||||
},
|
||||
],
|
||||
useAlgoliaSearch: [{}, {}, {}],
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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 }) => (<div>{children}</div>),
|
||||
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 <JobTitleSelect> component
|
||||
searchClient: {},
|
||||
productSearchIndex: {},
|
||||
jobSearchIndex: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const SkillsBuilderWrapperWithContext = (value = contextValue) => (
|
||||
<IntlProvider locale="en">
|
||||
<SkillsBuilderContext.Provider value={value}>
|
||||
<SkillsBuilderModal />
|
||||
</SkillsBuilderContext.Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user