feat: SkillsBuilder career interests selection

This commit is contained in:
Maxwell Frank
2023-02-27 20:03:25 +00:00
parent 87487e37d7
commit ddff5364ce
13 changed files with 171 additions and 37 deletions

View File

@@ -2,7 +2,7 @@ import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREEST,
REMOVE_CAREER_INTEREST,
} from './constants';
export const setGoal = (payload) => ({
@@ -21,6 +21,6 @@ export const addCareerInterest = (payload) => ({
});
export const removeCareerInterest = (payload) => ({
type: REMOVE_CAREER_INTEREEST,
type: REMOVE_CAREER_INTEREST,
payload,
});

View File

@@ -2,7 +2,7 @@
export const SET_GOAL = 'SET_GOAL';
export const SET_CURRENT_JOB_TITLE = 'SET_CURRENT_JOB_TITLE';
export const ADD_CAREER_INTEREST = 'ADD_CAREER_INTEREST';
export const REMOVE_CAREER_INTEREEST = 'REMOVE_CAREER_INTEREEST';
export const REMOVE_CAREER_INTEREST = 'REMOVE_CAREER_INTEREST';
// Stepper keys
export const STEP1 = 'select-your-preferences';

View File

@@ -2,7 +2,7 @@ import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREEST,
REMOVE_CAREER_INTEREST,
} from './constants';
export function skillsReducer(state, action) {
@@ -22,7 +22,7 @@ export function skillsReducer(state, action) {
...state,
careerInterests: [...state.careerInterests, action.payload],
};
case REMOVE_CAREER_INTEREEST:
case REMOVE_CAREER_INTEREST:
return {
...state,
careerInterests: state.careerInterests.filter(interest => interest !== action.payload),

View File

@@ -3,7 +3,7 @@ import {
SET_GOAL,
SET_CURRENT_JOB_TITLE,
ADD_CAREER_INTEREST,
REMOVE_CAREER_INTEREEST,
REMOVE_CAREER_INTEREST,
} from '../constants';
describe('skillsReducer', () => {
@@ -48,7 +48,7 @@ describe('skillsReducer', () => {
};
const returnedState = skillsReducer(
testStateWithInterest,
{ type: REMOVE_CAREER_INTEREEST, payload: newCareerInterestPayload },
{ type: REMOVE_CAREER_INTEREST, payload: newCareerInterestPayload },
);
const finalState = {
...testStateWithInterest,

View File

@@ -33,7 +33,7 @@ const SkillsBuilderModal = () => {
<ModalDialog
title="Skills Builder"
size="fullscreen"
className="skills-builder-modal"
className="skills-builder-modal bg-light-200"
isOpen
onClose={onCloseHandle}
>

View File

@@ -0,0 +1,35 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import {
IconButton, Icon,
} from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Close } from '@edx/paragon/icons';
import { SkillsBuilderContext } from '../../skills-builder-context';
import { removeCareerInterest } from '../../data/actions';
import messages from './messages';
const CareerInterestCard = ({ interest }) => {
const intl = useIntl();
const { dispatch } = useContext(SkillsBuilderContext);
return (
<div className="d-flex justify-content-between align-items-center pb-2 pr-2 pl-4 rounded shadow-sm">
<p className="pt-4">
{interest}
</p>
<IconButton
iconAs={Icon}
src={Close}
alt={`${intl.formatMessage(messages.removeCareerInterestButtonAltText)} ${interest}`}
onClick={() => dispatch(removeCareerInterest(interest))}
/>
</div>
);
};
CareerInterestCard.propTypes = {
interest: PropTypes.string.isRequired,
};
export default CareerInterestCard;

View File

@@ -1,16 +1,46 @@
import React from 'react';
import React, { useContext } from 'react';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Stack, Row, Col } from '@edx/paragon';
import { InstantSearch } from 'react-instantsearch-hooks-web';
import JobTitleInstantSearch from './JobTitleInstantSearch';
import CareerInterestCard from './CareerInterestCard';
import { addCareerInterest } from '../../data/actions';
import { SkillsBuilderContext } from '../../skills-builder-context';
import messages from './messages';
const CareerInterestSelect = () => (
<div>
<h4>
<FormattedMessage {...messages.careerInterestPrompt} />
</h4>
<p>
JobTitleAutosuggest component can be reused here
</p>
</div>
);
const CareerInterestSelect = () => {
const { state, dispatch, algolia } = useContext(SkillsBuilderContext);
const { careerInterests } = state;
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) {
dispatch(addCareerInterest(value));
}
};
return (
<Stack gap={2}>
<h4>
<FormattedMessage {...messages.careerInterestPrompt} />
</h4>
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
<JobTitleInstantSearch onSelected={handleCareerInterestSelect} />
</InstantSearch>
<Row>
{careerInterests.map((interest, index) => (
// eslint-disable-next-line react/no-array-index-key
<Col key={index} xs={12} sm={4} className="mb-4">
<CareerInterestCard interest={interest} />
</Col>
))}
</Row>
</Stack>
);
};
export default CareerInterestSelect;

View File

@@ -21,6 +21,7 @@ const GoalDropdown = () => {
as="select"
value={currentGoal}
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>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
Form,
@@ -13,9 +13,12 @@ const JobTitleInstantSearch = (props) => {
const handleAutosuggestChange = (value) => {
setJobInput(value);
refine(value);
};
useEffect(() => {
refine(jobInput);
}, [jobInput, refine]);
return (
<Form.Autosuggest
value={jobInput}

View File

@@ -1,8 +1,8 @@
import React, { useContext } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
Form, Stack,
} from '@edx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { InstantSearch } from 'react-instantsearch-hooks-web';
import { setCurrentJobTitle } from '../../data/actions';
@@ -14,6 +14,10 @@ const JobTitleSelect = () => {
const { dispatch, algolia } = useContext(SkillsBuilderContext);
const { searchClient } = algolia;
const handleCurrentJobTitleSelect = (value) => {
dispatch(setCurrentJobTitle(value));
};
// 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));
@@ -24,7 +28,7 @@ const JobTitleSelect = () => {
<FormattedMessage {...messages.jobTitlePrompt} />
</h4>
<InstantSearch searchClient={searchClient} indexName={getConfig().ALGOLIA_JOBS_INDEX_NAME}>
<JobTitleInstantSearch onSelected={(value) => dispatch(setCurrentJobTitle(value))} />
<JobTitleInstantSearch onSelected={handleCurrentJobTitleSelect} />
</InstantSearch>
<Form.Group>
<Form.CheckboxSet

View File

@@ -4,7 +4,6 @@ import {
} from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { SkillsBuilderContext } from '../../skills-builder-context';
import GoalSelect from './GoalSelect';
import JobTitleSelect from './JobTitleSelect';
import CareerInterestSelect from './CareerInterestSelect';
@@ -15,21 +14,23 @@ const SelectPreferences = () => {
const { currentGoal, currentJobTitle } = state;
return (
<Stack gap={5}>
<p className="lead">
<>
<p className="lead mb-5">
<FormattedMessage {...messages.skillsBuilderDescription} />
</p>
<Stack gap={4}>
<GoalSelect />
<GoalSelect />
{currentGoal && (
<JobTitleSelect />
)}
{currentGoal && (
<JobTitleSelect />
)}
{currentJobTitle && (
<CareerInterestSelect />
)}
</Stack>
{currentJobTitle && (
<CareerInterestSelect />
)}
</Stack>
</>
);
};

View File

@@ -61,6 +61,10 @@ const messages = defineMessages({
defaultMessage: 'What careers are you interested in?',
description: 'Prompts the user to select careers they are interested in pursuing.',
},
removeCareerInterestButtonAltText: {
id: 'career.interest.remove.button.alt.text',
defaultMessage: 'Remove career interest: ',
},
});
export default messages;

View File

@@ -71,14 +71,27 @@ describe('skills-builder', () => {
},
),
);
expect(screen.getByText('Next, search and select your current job title')).toBeTruthy();
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(dispatchMock).toHaveBeenCalled();
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 goal is selected', () => {
it('should render the third prompt if a current job title is selected', () => {
render(
SkillsBuilderWrapperWithContext(
{
@@ -93,4 +106,47 @@ describe('skills-builder', () => {
);
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);
});
});