From b070d8a538defd525ce40f0d3f9b5ab99307a840 Mon Sep 17 00:00:00 2001 From: Maxwell Frank Date: Tue, 18 Jul 2023 16:25:31 +0000 Subject: [PATCH] refactor: card carousel to grid --- .../skillsBuilderModal.scss | 12 ++- .../view-results/CarouselStack.jsx | 79 -------------- .../view-results/ProductCardGrid.jsx | 46 ++++++++ .../view-results/ProductTypeBanner.jsx | 102 ++++++++++++++++++ .../view-results/RecommendationCard.jsx | 2 +- .../view-results/RecommendationStack.jsx | 64 +++++++++++ .../view-results/ViewResults.jsx | 25 ++--- .../view-results/data/constants.js | 10 +- .../view-results/data/hooks.js | 2 +- .../view-results/messages.js | 42 +++++++- .../view-results/test/ViewResults.test.jsx | 62 +++++------ .../test/__mocks__/jobSkills.mockData.js | 22 ++++ .../utils/extractProductKeys.js | 17 +++ .../utils/tests/extractProductKeys.test.js | 43 ++++++++ 14 files changed, 388 insertions(+), 140 deletions(-) delete mode 100644 src/skills-builder/skills-builder-modal/view-results/CarouselStack.jsx create mode 100644 src/skills-builder/skills-builder-modal/view-results/ProductCardGrid.jsx create mode 100644 src/skills-builder/skills-builder-modal/view-results/ProductTypeBanner.jsx create mode 100644 src/skills-builder/skills-builder-modal/view-results/RecommendationStack.jsx create mode 100644 src/skills-builder/utils/extractProductKeys.js create mode 100644 src/skills-builder/utils/tests/extractProductKeys.test.js 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); + }); +});