diff --git a/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss b/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss
index 10ea166..eae2cc9 100644
--- a/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss
+++ b/src/skills-builder/skills-builder-modal/skillsBuilderModal.scss
@@ -4,6 +4,10 @@
}
}
+.chip-max-width {
+ max-width: 18rem;
+}
+
$breakpoint-medium: 992px;
@media (max-width: $breakpoint-medium) {
.med-min-height {
@@ -17,6 +21,10 @@ $breakpoint-medium: 992px;
}
}
-.chip-max-width {
- max-width: 16rem;
+@media (min-width: $breakpoint-medium) {
+ .product-card {
+ .chip-max-width {
+ width: 100%;
+ }
+ }
}
diff --git a/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx b/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx
deleted file mode 100644
index efa8b26..0000000
--- a/src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import React from 'react';
-import { CardCarousel } from '@edx/paragon';
-import { useIntl } from '@edx/frontend-platform/i18n';
-import { sendTrackEvent } from '@edx/frontend-platform/analytics';
-import RecommendationCard from './RecommendationCard';
-import messages from './messages';
-
-const CarouselStack = ({ selectedRecommendations, productTypeNames }) => {
- const { formatMessage } = useIntl();
- const { id: jobId, name: jobName, recommendations } = selectedRecommendations;
- const courseKeys = recommendations.course?.map(rec => ({
- title: rec.title,
- courserun_key: rec.active_run_key,
- }));
-
- 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));
-
- // 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,
- })}
-
- );
-
- const handleCourseCardClick = (courseKey, productType) => {
- sendTrackEvent(
- 'edx.skills_builder.recommendation.click',
- {
- app_name: 'skills_builder',
- category: 'skills_builder',
- page: 'skills_builder',
- courserun_key: courseKey,
- product_type: productType,
- selected_recommendations: {
- job_id: jobId,
- job_name: jobName,
- courserun_keys: courseKeys,
- },
- },
- );
- };
-
- return (
- productTypeNames.map(productType => (
-
- {recommendations[productType].map(rec => (
-
- ))}
-
- )));
-};
-
-export default CarouselStack;
diff --git a/src/skills-builder/skills-builder-modal/view-results/ProductCardGrid.jsx b/src/skills-builder/skills-builder-modal/view-results/ProductCardGrid.jsx
new file mode 100644
index 0000000..d9aaa55
--- /dev/null
+++ b/src/skills-builder/skills-builder-modal/view-results/ProductCardGrid.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { CardGrid } from '@edx/paragon';
+import RecommendationCard from './RecommendationCard';
+
+const ProductCardGrid = ({
+ productTypeName, productTypeRecommendations, handleCourseCardClick, isExpanded,
+}) => (
+
+ {isExpanded ? (
+ productTypeRecommendations?.map(rec => (
+
+ ))
+ ) : (
+ productTypeRecommendations?.slice(0, 4).map(rec => (
+
+ ))
+ )}
+
+);
+
+ProductCardGrid.propTypes = {
+ productTypeRecommendations: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
+ productTypeName: PropTypes.string.isRequired,
+ handleCourseCardClick: PropTypes.func.isRequired,
+ isExpanded: PropTypes.bool.isRequired,
+};
+
+export default ProductCardGrid;
diff --git a/src/skills-builder/skills-builder-modal/view-results/ProductTypeBanner.jsx b/src/skills-builder/skills-builder-modal/view-results/ProductTypeBanner.jsx
new file mode 100644
index 0000000..df055ca
--- /dev/null
+++ b/src/skills-builder/skills-builder-modal/view-results/ProductTypeBanner.jsx
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Button, Icon, Stack, useMediaQuery, breakpoints,
+} from '@edx/paragon';
+import { KeyboardArrowDown, KeyboardArrowUp } from '@edx/paragon/icons';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import messages from './messages';
+import {
+ DEGREE,
+ BOOT_CAMP,
+ EXECUTIVE_EDUCATION,
+ PROGRAM,
+ COURSE,
+} from './data/constants';
+
+const ProductTypeBanner = ({
+ productTypeName, jobName, numberResults, handleShowAllButtonClick, isExpanded,
+}) => {
+ const { formatMessage } = useIntl();
+ const isLarge = useMediaQuery({ minWidth: breakpoints.large.minWidth });
+
+ const normalizeProductTitle = () => {
+ switch (productTypeName) {
+ case COURSE:
+ return formatMessage(messages.productTypeCourseText);
+ case BOOT_CAMP:
+ return formatMessage(messages.productTypeBootCampText);
+ case EXECUTIVE_EDUCATION:
+ return formatMessage(messages.productTypeExecutiveEducationText);
+ case DEGREE:
+ return formatMessage(messages.productTypeDegreeText);
+ case PROGRAM:
+ return formatMessage(messages.productTypeProgramText);
+ default:
+ return '';
+ }
+ };
+
+ const renderTitle = () => {
+ const productTypeHeaderText = normalizeProductTitle(productTypeName);
+ return (
+
+ {formatMessage(messages.productRecommendationsHeaderText, {
+ productTypeHeaderText,
+ jobName,
+ })}
+
+ );
+ };
+
+ const infoStackProps = {
+ gap: 2,
+ direction: isLarge ? 'horizontal' : 'vertical',
+ className: 'justify-content-between align-items-start',
+ };
+
+ const showAllButtonProps = {
+ variant: 'link',
+ className: 'p-0',
+ onClick: () => handleShowAllButtonClick(productTypeName),
+ 'aria-expanded': isExpanded ? 'true' : 'false',
+ 'aria-controls': `card-grid-${productTypeName}`,
+ 'data-testid': `${productTypeName}-expand-button`,
+ };
+
+ return (
+
+ {renderTitle(productTypeName)}
+
+
+ {formatMessage(messages.productTypeBannerResults, {
+ numberResults,
+ })}
+
+
+
+
+ );
+};
+
+ProductTypeBanner.propTypes = {
+ productTypeName: PropTypes.string.isRequired,
+ jobName: PropTypes.string.isRequired,
+ numberResults: PropTypes.number.isRequired,
+ handleShowAllButtonClick: PropTypes.func.isRequired,
+ isExpanded: PropTypes.bool.isRequired,
+};
+
+export default ProductTypeBanner;
diff --git a/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx b/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx
index fbd170f..9ed30ac 100644
--- a/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx
+++ b/src/skills-builder/skills-builder-modal/view-results/RecommendationCard.jsx
@@ -20,7 +20,7 @@ const RecommendationCard = ({ rec, productType, handleCourseCardClick }) => {
return (
handleCourseCardClick(courseKey, productType)}
>
{
+ const [showAllButtonClickedList, setShowButtonClickedList] = useState([]);
+ const { id: jobId, name: jobName, recommendations } = selectedRecommendations;
+
+ const handleCourseCardClick = (courseKey, productTypeName) => {
+ sendTrackEvent(
+ 'edx.skills_builder.recommendation.click',
+ {
+ app_name: 'skills_builder',
+ category: 'skills_builder',
+ page: 'skills_builder',
+ courserun_key: courseKey,
+ product_type: productTypeName,
+ selected_recommendations: {
+ job_id: jobId,
+ job_name: jobName,
+ courserun_keys: extractProductKeys(recommendations),
+ },
+ },
+ );
+ };
+
+ const handleShowAllButtonClick = (type) => {
+ if (showAllButtonClickedList.includes(type)) {
+ setShowButtonClickedList(prev => prev.filter(item => item !== type));
+ return;
+ }
+ setShowButtonClickedList(prev => [...prev, type]);
+ };
+
+ return (
+ productTypeNames.map(productTypeName => {
+ const productTypeRecommendations = recommendations[productTypeName];
+ const numberResults = productTypeRecommendations?.length;
+ const isExpanded = showAllButtonClickedList.includes(productTypeName);
+
+ return (
+
+
+
+
+ );
+ }));
+};
+
+export default RecommendationStack;
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 a0e4186..715c892 100644
--- a/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx
+++ b/src/skills-builder/skills-builder-modal/view-results/ViewResults.jsx
@@ -1,5 +1,5 @@
import React, {
- useContext, useEffect, useState,
+ useContext, useEffect, useState, useRef,
} from 'react';
import {
Stack, Row, Alert, Spinner,
@@ -10,10 +10,11 @@ import { CheckCircle, ErrorOutline } from '@edx/paragon/icons';
import { SkillsBuilderContext } from '../../skills-builder-context';
import RelatedSkillsSelectableBoxSet from './RelatedSkillsSelectableBoxSet';
import messages from './messages';
-import CarouselStack from './CarouselStack';
+import RecommendationStack from './RecommendationStack';
import { getRecommendations } from './data/service';
import { useProductTypes } from './data/hooks';
+import { extractProductKeys } from '../../utils/extractProductKeys';
const ViewResults = () => {
const { formatMessage } = useIntl();
@@ -28,12 +29,12 @@ const ViewResults = () => {
const [isLoading, setIsLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
- const productTypes = useProductTypes();
+ const productTypes = useRef(useProductTypes());
useEffect(() => {
const getAllRecommendations = async () => {
// eslint-disable-next-line max-len
- const { jobInfo, results } = await getRecommendations(jobSearchIndex, productSearchIndex, careerInterests, productTypes);
+ const { jobInfo, results } = await getRecommendations(jobSearchIndex, productSearchIndex, careerInterests, productTypes.current);
setJobSkillsList(jobInfo);
setSelectedJobTitle(results[0].name);
@@ -47,10 +48,7 @@ const ViewResults = () => {
job_id: results[0].id,
job_name: results[0].name,
/* We extract the title and course key into an array of objects */
- courserun_keys: results[0].recommendations.course?.map(rec => ({
- title: rec.title,
- courserun_key: rec.active_run_key,
- })),
+ courserun_keys: extractProductKeys(results[0].recommendations),
},
is_default: true,
});
@@ -73,10 +71,6 @@ const ViewResults = () => {
setSelectedJobTitle(value);
const currentSelection = productRecommendations.find(rec => rec.name === value);
const { id: jobId, name: jobName, recommendations } = currentSelection;
- const courseKeys = recommendations.course?.map(rec => ({
- title: rec.title,
- courserun_key: rec.active_run_key,
- }));
/*
The is_default value will be set to false for any selections made by the user.
This code is intentionally duplicated from the event that fires in the useEffect for fetching recommendations.
@@ -91,7 +85,7 @@ const ViewResults = () => {
selected_recommendations: {
job_id: jobId,
job_name: jobName,
- courserun_keys: courseKeys,
+ courserun_keys: extractProductKeys(recommendations),
},
is_default: false,
});
@@ -136,7 +130,10 @@ const ViewResults = () => {
onChange={handleJobTitleChange}
/>
-
+
)
);
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
index 182f783..a97ce50 100644
--- a/src/skills-builder/skills-builder-modal/view-results/data/constants.js
+++ b/src/skills-builder/skills-builder-modal/view-results/data/constants.js
@@ -1,11 +1,11 @@
export const COURSE = 'course';
-const BOOT_CAMP = 'boot_camp';
-const EXECUTIVE_EDUCATION = 'executive_education';
-const DEGREE = '2U_degree';
-const PROGRAM = 'program';
+export const BOOT_CAMP = 'boot_camp';
+export const EXECUTIVE_EDUCATION = 'executive_education';
+export const DEGREE = '2U_degree';
+export const PROGRAM = 'program';
// This array is used to determine the validity of product types as they are passed through the query string
-export const productTypes = [
+export const productTypeNames = [
DEGREE,
BOOT_CAMP,
EXECUTIVE_EDUCATION,
diff --git a/src/skills-builder/skills-builder-modal/view-results/data/hooks.js b/src/skills-builder/skills-builder-modal/view-results/data/hooks.js
index 53aabb8..e72a3d0 100644
--- a/src/skills-builder/skills-builder-modal/view-results/data/hooks.js
+++ b/src/skills-builder/skills-builder-modal/view-results/data/hooks.js
@@ -1,5 +1,5 @@
import { useLocation } from 'react-router-dom';
-import { productTypes as acceptedProductTypes, COURSE } from './constants';
+import { productTypeNames as acceptedProductTypes, COURSE } from './constants';
const defaultSetting = [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 91660d6..d463e29 100644
--- a/src/skills-builder/skills-builder-modal/view-results/messages.js
+++ b/src/skills-builder/skills-builder-modal/view-results/messages.js
@@ -23,9 +23,49 @@ const messages = defineMessages({
},
productRecommendationsHeaderText: {
id: 'product.recommendations.header.text',
- defaultMessage: '{productType} recommendations for {jobName}',
+ defaultMessage: '"{jobName}" {productTypeHeaderText}',
description: 'Header text for a carousel of product recommendations.',
},
+ productTypeBannerResults: {
+ id: 'product.type.banner.results',
+ defaultMessage: '{numberResults} results on edX',
+ description: 'The number of total results',
+ },
+ productTypeBannerShowAll: {
+ id: 'product.type.banner.show.all',
+ defaultMessage: 'Show all ({numberResults}) {arrowIcon}',
+ description: 'Show all button text',
+ },
+ productTypeBannerShowLess: {
+ id: 'product.type.banner.show.less',
+ defaultMessage: 'Show less ({numberResults}) {arrowIcon}',
+ description: 'Show less button text',
+ },
+ productTypeCourseText: {
+ id: 'product.type.course.text',
+ defaultMessage: 'courses',
+ description: 'Header text for courses',
+ },
+ productTypeProgramText: {
+ id: 'product.type.program.text',
+ defaultMessage: 'programs',
+ description: 'Header text for programs',
+ },
+ productTypeBootCampText: {
+ id: 'product.type.boot_camp.text',
+ defaultMessage: 'bootcamps',
+ description: 'Header text for bootcamps',
+ },
+ productTypeExecutiveEducationText: {
+ id: 'product.type.executive_education.text',
+ defaultMessage: 'executive education',
+ description: 'Header text for executive education',
+ },
+ productTypeDegreeText: {
+ id: 'product.type.degree.text',
+ defaultMessage: 'degrees',
+ description: 'Header text for degrees',
+ },
});
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
index d4df5fa..f007845 100644
--- 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
@@ -5,11 +5,16 @@ import { mergeConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { SkillsBuilderWrapperWithContext, contextValue } from '../../../test/setupSkillsBuilder';
import { getProductRecommendations } from '../../../utils/search';
+import { mockData } from '../../../test/__mocks__/jobSkills.mockData';
jest.mock('@edx/frontend-platform/analytics', () => ({
sendTrackEvent: jest.fn(),
}));
+jest.mock('../data/hooks', () => ({
+ useProductTypes: () => ['course', 'boot_camp', 'executive_education', '2U_degree', 'program'],
+}));
+
const renderSkillsBuilderWrapper = (
value = {
...contextValue,
@@ -25,7 +30,7 @@ const renderSkillsBuilderWrapper = (
};
describe('view-results', () => {
- beforeAll(() => {
+ beforeAll(async () => {
mergeConfig({
ALGOLIA_JOBS_INDEX_NAME: 'test-job-index-name',
});
@@ -62,16 +67,7 @@ describe('view-results', () => {
selected_recommendations: {
job_id: 0,
job_name: 'Prospector',
- courserun_keys: [
- {
- title: 'Mining with the Mons',
- courserun_key: 'MONS101',
- },
- {
- title: 'The Art of Warren Upkeep',
- courserun_key: 'WAR101',
- },
- ],
+ courserun_keys: mockData.productKeys,
},
is_default: true,
},
@@ -80,13 +76,9 @@ describe('view-results', () => {
expect(sendTrackEvent).toHaveBeenCalledTimes(2);
});
- it('renders a carousel of components', () => {
- 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();
+ expect(screen.getByText('"Mirror Breaker" courses')).toBeTruthy();
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.recommendation.shown',
{
@@ -96,16 +88,7 @@ describe('view-results', () => {
selected_recommendations: {
job_id: 1,
job_name: 'Mirror Breaker',
- courserun_keys: [
- {
- title: 'Mining with the Mons',
- courserun_key: 'MONS101',
- },
- {
- title: 'The Art of Warren Upkeep',
- courserun_key: 'WAR101',
- },
- ],
+ courserun_keys: mockData.productKeys,
},
is_default: false,
},
@@ -128,7 +111,7 @@ describe('view-results', () => {
});
it('fires an event when a product recommendation is clicked', () => {
- fireEvent.click(screen.getByText('Mining with the Mons'));
+ fireEvent.click(screen.getAllByText('Mining with the Mons')[0]);
expect(sendTrackEvent).toHaveBeenCalledWith(
'edx.skills_builder.recommendation.click',
{
@@ -140,20 +123,25 @@ describe('view-results', () => {
selected_recommendations: {
job_id: 0,
job_name: 'Prospector',
- courserun_keys: [
- {
- title: 'Mining with the Mons',
- courserun_key: 'MONS101',
- },
- {
- title: 'The Art of Warren Upkeep',
- courserun_key: 'WAR101',
- },
- ],
+ courserun_keys: mockData.productKeys,
},
},
);
});
+
+ it('expands the list of recommendations when a KeyboardArrow Icon is clicked', () => {
+ expect(screen.queryAllByRole('button', { expanded: true })).toHaveLength(0);
+ fireEvent.click(screen.getByTestId('boot_camp-expand-button'));
+ expect(screen.queryAllByRole('button', { expanded: true })).toHaveLength(1);
+ });
+
+ it('renders a list of recommendations for each line of business', () => {
+ expect(screen.getByText('"Prospector" degrees')).toBeTruthy();
+ expect(screen.getByText('"Prospector" bootcamps')).toBeTruthy();
+ expect(screen.getByText('"Prospector" executive education')).toBeTruthy();
+ expect(screen.getByText('"Prospector" programs')).toBeTruthy();
+ expect(screen.getByText('"Prospector" courses')).toBeTruthy();
+ });
});
describe('fetch recommendations', () => {
diff --git a/src/skills-builder/test/__mocks__/jobSkills.mockData.js b/src/skills-builder/test/__mocks__/jobSkills.mockData.js
index 4edc012..ff259e7 100644
--- a/src/skills-builder/test/__mocks__/jobSkills.mockData.js
+++ b/src/skills-builder/test/__mocks__/jobSkills.mockData.js
@@ -70,6 +70,28 @@ export const mockData = {
},
],
useAlgoliaSearch: [{}, {}, {}],
+ productKeys: {
+ course: [
+ { title: 'Mining with the Mons', courserun_key: 'MONS101' },
+ { title: 'The Art of Warren Upkeep', courserun_key: 'WAR101' },
+ ],
+ '2U_degree': [
+ { title: 'Mining with the Mons', courserun_key: 'MONS101' },
+ { title: 'The Art of Warren Upkeep', courserun_key: 'WAR101' },
+ ],
+ program: [
+ { title: 'Mining with the Mons', courserun_key: 'MONS101' },
+ { title: 'The Art of Warren Upkeep', courserun_key: 'WAR101' },
+ ],
+ boot_camp: [
+ { title: 'Mining with the Mons', courserun_key: 'MONS101' },
+ { title: 'The Art of Warren Upkeep', courserun_key: 'WAR101' },
+ ],
+ executive_education: [
+ { title: 'Mining with the Mons', courserun_key: 'MONS101' },
+ { title: 'The Art of Warren Upkeep', courserun_key: 'WAR101' },
+ ],
+ },
};
export default mockData;
diff --git a/src/skills-builder/utils/extractProductKeys.js b/src/skills-builder/utils/extractProductKeys.js
new file mode 100644
index 0000000..4c2d4bc
--- /dev/null
+++ b/src/skills-builder/utils/extractProductKeys.js
@@ -0,0 +1,17 @@
+import { productTypeNames } from '../skills-builder-modal/view-results/data/constants';
+
+export const extractProductKeys = (recommendations) => (
+ Object.fromEntries(
+ productTypeNames.map(type => (
+ [
+ type,
+ recommendations[type]?.map(rec => ({
+ title: rec.title,
+ courserun_key: rec.active_run_key,
+ })),
+ ]
+ )),
+ )
+);
+
+export default extractProductKeys;
diff --git a/src/skills-builder/utils/tests/extractProductKeys.test.js b/src/skills-builder/utils/tests/extractProductKeys.test.js
new file mode 100644
index 0000000..f7f1a31
--- /dev/null
+++ b/src/skills-builder/utils/tests/extractProductKeys.test.js
@@ -0,0 +1,43 @@
+import { extractProductKeys } from '../extractProductKeys';
+import { mockData } from '../../test/__mocks__/jobSkills.mockData';
+import { productTypeNames } from '../../skills-builder-modal/view-results/data/constants';
+
+// Create mock recommendations object with product types as keys and the recommendations as values
+const mockRecommendations = Object.fromEntries(
+ productTypeNames.map(type => (
+ [
+ type,
+ mockData.productRecommendations,
+ ]
+ )),
+);
+
+// When the values are extracted, each course should only have two values: title and courserun_key
+const expectedCourseData = [
+ {
+ title: 'Mining with the Mons',
+ courserun_key: 'MONS101',
+ },
+ {
+ title: 'The Art of Warren Upkeep',
+ courserun_key: 'WAR101',
+ },
+];
+
+// Create an object with product types as keys and filtered recommendations as the values
+const expected = Object.fromEntries(
+ productTypeNames.map(type => (
+ [
+ type,
+ expectedCourseData,
+ ]
+ )),
+);
+
+describe('extractProductKeys utility', () => {
+ it('extracts the title and active_run_key', () => {
+ const results = extractProductKeys(mockRecommendations);
+
+ expect(results).toEqual(expected);
+ });
+});