From 96cef62817534014107397fb45512672bd224815 Mon Sep 17 00:00:00 2001 From: Alexander J Sheehan <67655836+alex-sheehan-edx@users.noreply.github.com> Date: Fri, 9 Dec 2022 13:04:33 -0500 Subject: [PATCH] feat: refactoring catalog search to support exec ed content (#268) --- .env.development | 3 +- .../CatalogNoResultsDeck.jsx | 21 +- .../CatalogNoResultsDeck.messages.js | 10 + .../CatalogNoResultsDeck.test.jsx | 26 ++ src/components/catalogPage/CatalogPage.jsx | 51 +++- .../catalogPage/CatalogPage.test.jsx | 42 +++ .../CatalogSearchResults.jsx | 270 +++++++++++------ .../CatalogSearchResults.test.jsx | 83 ++++++ src/components/catalogs/CatalogSearch.jsx | 279 +++++++++++------- .../catalogs/CatalogSearch.test.jsx | 15 +- src/components/courseCard/CourseCard.jsx | 27 +- src/components/courseCard/CourseCard.test.jsx | 35 ++- src/config/constants.js | 1 + src/config/index.js | 4 + src/constants.js | 9 +- src/utils/algoliaUtils.js | 45 ++- 16 files changed, 696 insertions(+), 225 deletions(-) diff --git a/.env.development b/.env.development index 630dec9b..564cd9fc 100644 --- a/.env.development +++ b/.env.development @@ -28,8 +28,9 @@ MINIMAL_HEADER=true EDX_FOR_BUSINESS_TITLE='' EDX_FOR_ONLINE_EDU_TITLE='' EDX_ENTERPRISE_ALACARTE_TITLE='' -FEATURE_LANGUAGE_FACET=True +FEATURE_LANGUAGE_FACET=true HOTJAR_APP_ID='' HOTJAR_VERSION=6 HOTJAR_DEBUG=true HUBSPOT_MARKETING_URL='http://example.com' +EXEC_ED_INCLUSION=true diff --git a/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.jsx b/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.jsx index f30a806e..6a221cf1 100644 --- a/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.jsx +++ b/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.jsx @@ -7,7 +7,7 @@ import PropTypes from 'prop-types'; import { CONTENT_TYPE_COURSE, CONTENT_TYPE_PROGRAM, - EDX_COURSES_COURSE_TYPES, + EXECUTIVE_EDUCATION_2U_COURSE_TYPE, NO_RESULTS_DECK_ITEM_COUNT, NO_RESULTS_PAGE_SIZE, NO_RESULTS_PAGE_ITEM_COUNT, @@ -24,7 +24,6 @@ function CatalogNoResultsDeck({ columns, renderCardComponent, contentType, - courseType, }) { const [defaultData, setDefaultData] = useState([]); const [apiError, setApiError] = useState(false); @@ -42,12 +41,9 @@ function CatalogNoResultsDeck({ useEffect(() => { const defaultCoursesRefinements = { enterprise_catalog_query_titles: selectedCatalog, - content_type: contentType, + learning_type: contentType, }; - if (contentType === CONTENT_TYPE_COURSE) { - // if a course type is not specified, default to edx course content - defaultCoursesRefinements.course_type = courseType !== null ? [courseType] : EDX_COURSES_COURSE_TYPES; - } + EnterpriseCatalogApiService.fetchDefaultCoursesInCatalogWithFacets( defaultCoursesRefinements, ) @@ -59,7 +55,7 @@ function CatalogNoResultsDeck({ setApiError(true); logError(err); }); - }, [selectedCatalog, contentType, courseType]); + }, [selectedCatalog, contentType]); let defaultDeckTitle; let alertText; @@ -70,6 +66,13 @@ function CatalogNoResultsDeck({ defaultDeckTitle = intl.formatMessage( messages['catalogSearchResults.DefaultCourseDeckTitle'], ); + } else if (contentType === EXECUTIVE_EDUCATION_2U_COURSE_TYPE) { + alertText = intl.formatMessage( + messages['catalogSearchResults.NoResultsExecEdCourseBannerText'], + ); + defaultDeckTitle = intl.formatMessage( + messages['catalogSearchResults.DefaultExecEdCourseDeckTitle'], + ); } else if (contentType === CONTENT_TYPE_PROGRAM) { alertText = intl.formatMessage( messages['catalogSearchResults.NoResultsProgramBannerText'], @@ -131,12 +134,10 @@ CatalogNoResultsDeck.defaultProps = { renderCardComponent: () => {}, columns: [], contentType: '', - courseType: null, }; CatalogNoResultsDeck.propTypes = { contentType: PropTypes.string, - courseType: PropTypes.string, intl: intlShape.isRequired, setCardView: PropTypes.func, renderCardComponent: PropTypes.func, diff --git a/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.messages.js b/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.messages.js index 5a8060cb..30dc43d2 100644 --- a/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.messages.js +++ b/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.messages.js @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'Popular Courses', description: 'Popular Courses table header.', }, + 'catalogSearchResults.DefaultExecEdCourseDeckTitle': { + id: 'catalogSearchResults.popularExecEdCourses', + defaultMessage: 'Popular Executive Education Courses', + description: 'Popular Executive Education Courses table header.', + }, 'catalogSearchResults.DefaultProgramDeckTitle': { id: 'catalogSearchResults.popularPrograms', defaultMessage: 'Popular Programs', @@ -21,6 +26,11 @@ const messages = defineMessages({ defaultMessage: 'No courses were found that match your search. Try ', description: 'No results course alert modal text.', }, + 'catalogSearchResults.NoResultsExecEdCourseBannerText': { + id: 'catalogSearchResults.NoResultsExecEdCourseBannerText', + defaultMessage: 'No Executive Education courses were found that match your search. Try ', + description: 'No results exec ed course alert modal text.', + }, 'catalogSearchResults.NoResultsProgramBannerText': { id: 'catalogSearchResults.NoResultsProgramBannerText', defaultMessage: 'No programs were found that match your search. Try ', diff --git a/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.test.jsx b/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.test.jsx index 3df3a067..9e754b0b 100644 --- a/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.test.jsx +++ b/src/components/catalogNoResultsDeck/CatalogNoResultsDeck.test.jsx @@ -70,6 +70,23 @@ const defaultProps = { }, }; +const execEdProps = { + setCardView: jest.fn(), + columns: [], + renderCardComponent: jest.fn(), + contentType: 'executive-education-2u', + intl: { + formatMessage: (header) => header.defaultMessage, + formatDate: () => {}, + formatTime: () => {}, + formatRelative: () => {}, + formatNumber: () => {}, + formatPlural: () => {}, + formatHTMLMessasge: () => {}, + now: () => {}, + }, +}; + describe('catalog no results deck works as expected', () => { test('it displays no results alert text', () => { mockCatalogApiService.mockResolvedValue(csvData); @@ -107,4 +124,13 @@ describe('catalog no results deck works as expected', () => { ).not.toBeInTheDocument(); expect(logError).toBeCalled(); }); + test('shows executive education alert text', async () => { + render( + + + , + ); + expect(screen.getByText('No Executive Education courses were found that match your search. Try')).toBeInTheDocument(); + expect(screen.getByText('Popular Executive Education Courses')).toBeInTheDocument(); + }); }); diff --git a/src/components/catalogPage/CatalogPage.jsx b/src/components/catalogPage/CatalogPage.jsx index 09507b6b..a921e9c2 100644 --- a/src/components/catalogPage/CatalogPage.jsx +++ b/src/components/catalogPage/CatalogPage.jsx @@ -14,20 +14,27 @@ import Subheader from '../subheader/subheader'; import Hero from '../hero/Hero'; import messages from './CatalogPage.messages'; import CatalogSelectionDeck from '../catalogSelectionDeck/CatalogSelectionDeck'; +import features from '../../config'; import { AVAILABILITY_REFINEMENT, AVAILABILITY_REFINEMENT_DEFAULTS, - CONTENT_TYPE_REFINEMENT, + EXECUTIVE_EDUCATION_2U_COURSE_TYPE, QUERY_TITLE_REFINEMENT, HIDE_CARDS_REFINEMENT, TRACKING_APP_NAME, } from '../../constants'; -const contentType = { - attribute: 'content_type', - title: 'Type', +const LEARNING_TYPE_REFINEMENT = 'learning_type'; +const learningType = { + attribute: 'learning_type', + title: 'Learning Type', }; -SEARCH_FACET_FILTERS.push(contentType); +// Add learning_type to the search facet filters if it doesn't exist in the list yet. +if ( + !SEARCH_FACET_FILTERS.some((filter) => filter.attribute === 'learning_type') +) { + SEARCH_FACET_FILTERS.push(learningType); +} function CatalogPage({ intl }) { const config = getConfig(); @@ -48,9 +55,37 @@ function CatalogPage({ intl }) { reloadPage = true; } + // Remove the `learning_type = executive education` filter if the feature flag is disabled or if the selected + // catalog isn't `a la carte` + if ( + (!features.EXEC_ED_INCLUSION + || (config.EDX_ENTERPRISE_ALACARTE_TITLE + && loadedSearchParams.get(LEARNING_TYPE_REFINEMENT) + && !( + loadedSearchParams.get(QUERY_TITLE_REFINEMENT) + === config.EDX_ENTERPRISE_ALACARTE_TITLE + ))) + && loadedSearchParams.get(LEARNING_TYPE_REFINEMENT) + === EXECUTIVE_EDUCATION_2U_COURSE_TYPE + ) { + const loadedLearningTypes = loadedSearchParams.getAll(LEARNING_TYPE_REFINEMENT); + if (loadedLearningTypes.length) { + loadedSearchParams.delete(LEARNING_TYPE_REFINEMENT); + loadedLearningTypes.forEach((type) => { + if (type !== EXECUTIVE_EDUCATION_2U_COURSE_TYPE) { + loadedSearchParams.append(LEARNING_TYPE_REFINEMENT, type); + } + }); + } + + reloadPage = true; + } + + // If we don't have specified learning_type(s) and we don't have specified catalog(s) then automatically add + // the `a la carte` catalog if ( config.EDX_ENTERPRISE_ALACARTE_TITLE - && !loadedSearchParams.get(CONTENT_TYPE_REFINEMENT) + && !loadedSearchParams.get(LEARNING_TYPE_REFINEMENT) && !loadedSearchParams.get(QUERY_TITLE_REFINEMENT) ) { loadedSearchParams.set( @@ -59,9 +94,11 @@ function CatalogPage({ intl }) { ); reloadPage = true; } + + // Ensure we have availability refinement(s) set by default if ( !loadedSearchParams.get(AVAILABILITY_REFINEMENT) - && !loadedSearchParams.get(CONTENT_TYPE_REFINEMENT) + && !loadedSearchParams.get(LEARNING_TYPE_REFINEMENT) ) { AVAILABILITY_REFINEMENT_DEFAULTS.map((a) => loadedSearchParams.append(AVAILABILITY_REFINEMENT, a)); reloadPage = true; diff --git a/src/components/catalogPage/CatalogPage.test.jsx b/src/components/catalogPage/CatalogPage.test.jsx index b9af02fc..b18e75bc 100644 --- a/src/components/catalogPage/CatalogPage.test.jsx +++ b/src/components/catalogPage/CatalogPage.test.jsx @@ -29,6 +29,14 @@ jest.mock('@edx/frontend-platform', () => ({ mockWindowLocations(); describe('CatalogPage', () => { + const OLD_ENV = process.env; + beforeEach(() => { + jest.resetModules(); // Most important - it clears the cache + process.env = { ...OLD_ENV }; // Make a copy + }); + afterAll(() => { + process.env = OLD_ENV; // Restore old environment + }); it('renders a catalog page component', () => { const { container } = renderWithRouter(); expect(container.querySelector('.hero')).toBeInTheDocument(); @@ -61,4 +69,38 @@ describe('CatalogPage', () => { 'enterprise_catalog_query_titles=baz&availability=Available+Now&availability=Starting+Soon&availability=Upcoming', ); }); + it('accounts for exec ed inclusion feature flag', () => { + process.env.EXEC_ED_INCLUSION = false; + const location = { + ...window.location, + search: '?learning_type=executive-education-2u', + }; + Object.defineProperty(window, 'location', { + writable: true, + value: location, + }); + expect(window.location.search).toEqual('?learning_type=executive-education-2u'); + renderWithRouter(); + // Assert we've removed the exec ed learning type because the feature flag was disabled + expect(window.location.search).toEqual( + 'enterprise_catalog_query_titles=baz&availability=Available+Now&availability=Starting+Soon&availability=Upcoming', + ); + }); + it('accounts for exec ed disclusion when not a la carte is selected', () => { + process.env.EXEC_ED_INCLUSION = true; + const location = { + ...window.location, + search: '?learning_type=executive-education-2u&learning_type=ayylmao&enterprise_catalog_query_titles=foobar', + }; + Object.defineProperty(window, 'location', { + writable: true, + value: location, + }); + expect(window.location.search).toEqual('?learning_type=executive-education-2u&learning_type=ayylmao&enterprise_catalog_query_titles=foobar'); + renderWithRouter(); + // Assert learning type: exec ed has been removed but not learning type `ayylmao` + expect(window.location.search).toEqual( + 'enterprise_catalog_query_titles=foobar&learning_type=ayylmao', + ); + }); }); diff --git a/src/components/catalogSearchResults/CatalogSearchResults.jsx b/src/components/catalogSearchResults/CatalogSearchResults.jsx index 42334246..cd11d74d 100644 --- a/src/components/catalogSearchResults/CatalogSearchResults.jsx +++ b/src/components/catalogSearchResults/CatalogSearchResults.jsx @@ -10,7 +10,7 @@ import { intlShape, } from '@edx/frontend-platform/i18n'; import { - Alert, Button, CardView, DataTable, + Alert, Badge, Button, CardView, DataTable, } from '@edx/paragon'; import PropTypes from 'prop-types'; import queryString from 'query-string'; @@ -26,13 +26,19 @@ import Skeleton from 'react-loading-skeleton'; import { CONTENT_TYPE_COURSE, CONTENT_TYPE_PROGRAM, - CONTENT_TYPE_REFINEMENT, COURSE_TITLE, + EDX_COURSE_TITLE_DESC, + EXEC_ED_TITLE, + EXECUTIVE_EDUCATION_2U_COURSE_TYPE, HIDE_PRICE_REFINEMENT, + LEARNING_TYPE_REFINEMENT, PROGRAM_TITLE, + PROGRAM_TITLE_DESC, + TWOU_EXEC_ED_TITLE_DESC, } from '../../constants'; import { mapAlgoliaObjectToCourse, + mapAlgoliaObjectToExecEd, mapAlgoliaObjectToProgram, } from '../../utils/algoliaUtils'; import CatalogInfoModal from '../catalogInfoModal/CatalogInfoModal'; @@ -75,13 +81,18 @@ export function BaseCatalogSearchResults({ error, paginationComponent: PaginationComponent, contentType, - courseType, - setNoCourses, - setNoPrograms, + setNoContent, preview, }) { - const isProgramType = contentType === CONTENT_TYPE_PROGRAM; - const isCourseType = contentType === CONTENT_TYPE_COURSE; + const [isProgramType, setIsProgramType] = useState(); + const [isCourseType, setIsCourseType] = useState(); + const [isExecEdType, setIsExecEdType] = useState(); + + useEffect(() => { + setIsProgramType(contentType === CONTENT_TYPE_PROGRAM); + setIsCourseType(contentType === CONTENT_TYPE_COURSE); + setIsExecEdType(contentType === EXECUTIVE_EDUCATION_2U_COURSE_TYPE); + }, [contentType]); const TABLE_HEADERS = useMemo( () => ({ @@ -113,7 +124,7 @@ export function BaseCatalogSearchResults({ const { refinements, dispatch } = useContext(SearchContext); const nbHits = useNbHitsFromSearchResults(searchResults); - const linkText = `Show (${nbHits}) >`; + const linkText = `See all (${nbHits}) >`; const [selectedCourse, setSelectedCourse, isProgram, isCourse] = useSelectedCourse(); @@ -123,41 +134,56 @@ export function BaseCatalogSearchResults({ (row) => { if (isProgramType) { setSelectedCourse(mapAlgoliaObjectToProgram(row.original)); + } else if (isExecEdType) { + setSelectedCourse( + mapAlgoliaObjectToExecEd(row.original, intl, messages), + ); } else { setSelectedCourse( mapAlgoliaObjectToCourse(row.original, intl, messages), ); } }, - [intl, isProgramType, setSelectedCourse], + [intl, isProgramType, setSelectedCourse, isExecEdType], ); const cardClicked = useCallback( (card) => { if (isProgramType) { setSelectedCourse(mapAlgoliaObjectToProgram(card)); + } else if (isExecEdType) { + setSelectedCourse(mapAlgoliaObjectToExecEd(card, intl, messages)); } else { setSelectedCourse(mapAlgoliaObjectToCourse(card, intl, messages)); } }, - [intl, isProgramType, setSelectedCourse], + [intl, isProgramType, setSelectedCourse, isExecEdType], ); - const refinementClick = (content) => { - if (content === CONTENT_TYPE_COURSE) { + const refinementClick = () => { + if (isCourseType) { + dispatch( + setRefinementAction(LEARNING_TYPE_REFINEMENT, [CONTENT_TYPE_COURSE]), + ); + } else if (isExecEdType) { dispatch( - setRefinementAction(CONTENT_TYPE_REFINEMENT, [CONTENT_TYPE_COURSE]), + setRefinementAction(LEARNING_TYPE_REFINEMENT, [ + EXECUTIVE_EDUCATION_2U_COURSE_TYPE, + ]), ); } else { dispatch( - setRefinementAction(CONTENT_TYPE_REFINEMENT, [CONTENT_TYPE_PROGRAM]), + setRefinementAction(LEARNING_TYPE_REFINEMENT, [CONTENT_TYPE_PROGRAM]), ); } }; const renderCardComponent = (props) => { - if (isCourseType) { - return ; + if (contentType === CONTENT_TYPE_COURSE) { + return ; + } + if (contentType === EXECUTIVE_EDUCATION_2U_COURSE_TYPE) { + return ; } return ; }; @@ -208,6 +234,33 @@ export function BaseCatalogSearchResults({ [TABLE_HEADERS, TitleButtonComponent, CatalogBadgeComponent], ); + const execEdColumns = useMemo( + () => [ + { + Header: TABLE_HEADERS.courseName, + accessor: 'title', + Cell: TitleButtonComponent, + }, + { + Header: TABLE_HEADERS.partner, + accessor: 'partners[0].name', + }, + { + Header: TABLE_HEADERS.price, + accessor: 'entitlements', + Cell: ({ row }) => (row.values.entitlements[0].price + ? `$${row.values.entitlements[0].price}` + : null), + }, + { + Header: TABLE_HEADERS.catalogs, + accessor: 'enterprise_catalog_query_titles', + Cell: CatalogBadgeComponent, + }, + ], + [TABLE_HEADERS, TitleButtonComponent, CatalogBadgeComponent], + ); + const programColumns = useMemo( () => [ { @@ -240,6 +293,27 @@ export function BaseCatalogSearchResults({ [TABLE_HEADERS, TitleButtonComponent, CatalogBadgeComponent], ); + const [chosenColumn, setChosenColumns] = useState(courseColumns); + + // Select which columns to use depending on the current content type + useEffect(() => { + if (isCourseType) { + setChosenColumns(courseColumns); + } else if (isProgramType) { + setChosenColumns(programColumns); + } else { + setChosenColumns(execEdColumns); + } + }, [ + setChosenColumns, + courseColumns, + programColumns, + execEdColumns, + isCourseType, + isProgramType, + chosenColumn, + ]); + // substituting the price column with the availability dates per customer request ENT-5041 const page = refinements.page || (searchState ? searchState.page : 0); if (HIDE_PRICE_REFINEMENT in refinements) { @@ -260,7 +334,14 @@ export function BaseCatalogSearchResults({ }; function contentTitle() { - let subTitle = contentType === CONTENT_TYPE_COURSE ? COURSE_TITLE : PROGRAM_TITLE; + let subTitle; + if (isExecEdType) { + subTitle = EXEC_ED_TITLE; + } else if (isCourseType) { + subTitle = COURSE_TITLE; + } else { + subTitle = PROGRAM_TITLE; + } if (refinements.q && refinements.q !== '') { subTitle = `"${refinements.q}" ${subTitle} (${makePlural( nbHits, @@ -270,19 +351,22 @@ export function BaseCatalogSearchResults({ return subTitle; } - useEffect(() => { - if (contentType === CONTENT_TYPE_COURSE) { - if (searchResults?.nbHits === 0) { - setNoCourses(true); - } else { - setNoCourses(false); - } - } else if (searchResults?.nbHits === 0) { - setNoPrograms(true); + function contentTitleDescription() { + let desc; + if (isExecEdType) { + desc = TWOU_EXEC_ED_TITLE_DESC; + } else if (isCourseType) { + desc = EDX_COURSE_TITLE_DESC; } else { - setNoPrograms(false); + desc = PROGRAM_TITLE_DESC; } - }); + return desc; + } + + useEffect(() => { + setNoContent(searchResults === null || searchResults?.nbHits === 0); + }, [searchResults, setNoContent]); + const inputQuery = query.q; const dataTableActions = () => { @@ -319,9 +403,13 @@ export function BaseCatalogSearchResults({ ); } + function CustomRowStatus() { + return null; + } + return ( <> - {isCourseType && ( + {(isCourseType || isExecEdType) && ( setSelectedCourse(null)} @@ -337,7 +425,7 @@ export function BaseCatalogSearchResults({ /> )} {preview && isCourseType && searchResults?.nbHits !== 0 && ( - + )} -
- {preview && ( -
-

{contentTitle()}

- {searchResults?.nbHits !== 0 && ( - - )} -
- )} - {searchResults?.nbHits === 0 && ( - - )} +
+ )} + {searchResults?.nbHits !== 0 && ( - - - {cardView && ( - renderCardComponent(props)} - /> - )} - {!cardView && } - - {!preview && ( - - - - - )} - +

{contentTitleDescription()}

)} +
+ {searchResults?.nbHits === 0 && ( + + )} + {searchResults?.nbHits !== 0 && ( + + + {cardView && ( + renderCardComponent(props)} + /> + )} + {!cardView && } + + {!preview && ( + + + + + )} + + )} +
); } @@ -411,8 +511,7 @@ BaseCatalogSearchResults.defaultProps = { paginationComponent: SearchPagination, row: null, preview: false, - setNoCourses: () => {}, - setNoPrograms: () => {}, + setNoContent: () => {}, courseType: null, }; @@ -444,8 +543,7 @@ BaseCatalogSearchResults.propTypes = { contentType: PropTypes.string.isRequired, courseType: PropTypes.string, preview: PropTypes.bool, - setNoCourses: PropTypes.func, - setNoPrograms: PropTypes.func, + setNoContent: PropTypes.func, }; export default connectStateResults(injectIntl(BaseCatalogSearchResults)); diff --git a/src/components/catalogSearchResults/CatalogSearchResults.test.jsx b/src/components/catalogSearchResults/CatalogSearchResults.test.jsx index 05a255c2..cca17ae4 100644 --- a/src/components/catalogSearchResults/CatalogSearchResults.test.jsx +++ b/src/components/catalogSearchResults/CatalogSearchResults.test.jsx @@ -15,6 +15,7 @@ import messages from './CatalogSearchResults.messages'; import { CONTENT_TYPE_COURSE, CONTENT_TYPE_PROGRAM, + EXECUTIVE_EDUCATION_2U_COURSE_TYPE, HIDE_PRICE_REFINEMENT, } from '../../constants'; import EnterpriseCatalogApiService from '../../data/services/EnterpriseCatalogAPIService'; @@ -67,6 +68,8 @@ const TEST_CATALOGS_2 = ['baz', 'ayylmao']; const TEST_PROGRAM_NAME = 'test program'; +const TEST_EXEC_ED_NAME = 'test exec ed'; + const searchResults = { nbHits: 2, hitsPerPage: 10, @@ -87,6 +90,7 @@ const searchResults = { start: '2020-01-24T05:00:00Z', end: '2080-01-01T17:00:00Z', upgrade_deadline: 1892678399, + pacing_type: 'self_paced', }, }, { @@ -98,6 +102,12 @@ const searchResults = { original_image_url: '', availability: ['Available Now'], content_type: CONTENT_TYPE_COURSE, + advertised_course_run: { + start: '2020-01-24T05:00:00Z', + end: '2080-01-01T17:00:00Z', + upgrade_deadline: 1892678399, + pacing_type: 'self_paced', + }, }, ], page: 1, @@ -125,6 +135,35 @@ const searchResultsPrograms = { _state: { disjunctiveFacetsRefinements: { foo: 'bar' } }, }; +const searchResultsExecEd = { + nbHits: 1, + hitsPerPage: 10, + pageIndex: 10, + pageCount: 5, + nbPages: 6, + hits: [ + { + title: TEST_EXEC_ED_NAME, + partners: [{ name: TEST_PARTNER, logo_image_url: '' }], + authoring_organizations: [{ name: TEST_PARTNER, logo_image_url: '' }], + enterprise_catalog_query_titles: TEST_CATALOGS, + card_image_url: 'http://url.test2.location', + availability: ['Available Now'], + course_keys: [], + content_type: EXECUTIVE_EDUCATION_2U_COURSE_TYPE, + entitlements: [{ price: '100' }], + advertised_course_run: { + start: '2020-01-24T05:00:00Z', + end: '2080-01-01T17:00:00Z', + upgrade_deadline: 1892678399, + pacing_type: 'self_paced', + }, + }, + ], + page: 1, + _state: { disjunctiveFacetsRefinements: { foo: 'bar' } }, +}; + const defaultProps = { paginationComponent: PaginationComponent, searchResults, @@ -165,6 +204,26 @@ const programProps = { }, }; +const execEdProps = { + paginationComponent: PaginationComponent, + searchResults, + isSearchStalled: false, + searchState: { page: 1 }, + error: null, + contentType: EXECUTIVE_EDUCATION_2U_COURSE_TYPE, + // mock i18n requirements + intl: { + formatMessage: (header) => header.defaultMessage, + formatDate: () => {}, + formatTime: () => {}, + formatRelative: () => {}, + formatNumber: () => {}, + formatPlural: () => {}, + formatHTMLMessage: () => {}, + now: () => {}, + }, +}; + describe('Main Catalogs view works as expected', () => { const OLD_ENV = process.env; beforeEach(() => { @@ -380,6 +439,30 @@ describe('Main Catalogs view works as expected', () => { await act(() => screen.findByText('About this course')); expect(screen.queryByText('About this course')).toBeInTheDocument(); }); + test('exec ed search results text and card price', async () => { + process.env.EDX_FOR_BUSINESS_TITLE = 'ayylmao'; + process.env.EDX_FOR_ONLINE_EDU_TITLE = 'foo'; + process.env.EDX_ENTERPRISE_ALACARTE_TITLE = 'baz'; + renderWithRouter( + + + , + ); + + expect(screen.queryByText('Executive Education')).toBeInTheDocument(); + expect(screen.queryByText( + 'Immersive, instructor led online short courses designed to develop interpersonal, analytical, and critical thinking skills.', + )).toBeInTheDocument(); + expect(screen.queryByText('New')).toBeInTheDocument(); + + // click exec ed course card + const courseTitle = screen.getByText(TEST_EXEC_ED_NAME); + userEvent.click(courseTitle); + expect(screen.queryByText('$100')).toBeInTheDocument(); + }); test('all programs rendered when search results available', () => { process.env.EDX_FOR_BUSINESS_TITLE = 'ayylmao'; process.env.EDX_FOR_ONLINE_EDU_TITLE = 'foo'; diff --git a/src/components/catalogs/CatalogSearch.jsx b/src/components/catalogs/CatalogSearch.jsx index d378a34f..53d25812 100644 --- a/src/components/catalogs/CatalogSearch.jsx +++ b/src/components/catalogs/CatalogSearch.jsx @@ -1,5 +1,7 @@ /* eslint eqeqeq: "off" */ -import React, { useContext, useState, useMemo } from 'react'; +import React, { + useContext, useState, useMemo, useEffect, +} from 'react'; import { FormattedMessage, injectIntl, @@ -23,42 +25,100 @@ import { NUM_RESULTS_PROGRAM, NUM_RESULTS_PER_PAGE, } from '../../constants'; +import features from '../../config'; import CatalogSearchResults from '../catalogSearchResults/CatalogSearchResults'; import CatalogInfoModal from '../catalogInfoModal/CatalogInfoModal'; import { mapAlgoliaObjectToProgram, mapAlgoliaObjectToCourse, + mapAlgoliaObjectToExecEd, } from '../../utils/algoliaUtils'; import messages from '../catalogSearchResults/CatalogSearchResults.messages'; function CatalogSearch(intl) { const { - refinements: { content_type: contentType }, + refinements: { + learning_type: learningType, + enterprise_catalog_query_titles: enterpriseCatalogQueryTitles, + }, } = useContext(SearchContext); const { algoliaIndexName, searchClient } = useAlgoliaIndex(); - const courseFilter = `content_type:${CONTENT_TYPE_COURSE} AND NOT course_type:${EXECUTIVE_EDUCATION_2U_COURSE_TYPE}`; - const programFilter = `content_type:${CONTENT_TYPE_PROGRAM}`; - const combinedFilter = `content_type:${CONTENT_TYPE_COURSE} OR content_type:${CONTENT_TYPE_PROGRAM}`; + const courseFilter = `learning_type:${CONTENT_TYPE_COURSE}`; + const execEdFilter = `learning_type:${EXECUTIVE_EDUCATION_2U_COURSE_TYPE}`; + const programFilter = `learning_type:${CONTENT_TYPE_PROGRAM}`; const [noCourseResults, setNoCourseResults] = useState(false); const [noProgramResults, setNoProgramResults] = useState(false); + const [noExecEdResults, setNoExecEdResults] = useState(false); const [selectedSuggestedCourseType, setSelectedSuggestedCourseType] = useState(''); const [selectedSuggestedCourse, setSelectedSuggestedCourse] = useState({}); + const [specifiedContentType, setSpecifiedContentType] = useState(); + const [ + suggestedSearchContentTypeFilter, + setSuggestedSearchContentTypeFilter, + ] = useState(''); - let specifiedContentType; - if (contentType) { - if (contentType.length === 1) { - [specifiedContentType] = contentType; - } - } + const [contentWithResults, setContentWithResults] = useState([]); + const [contentWithoutResults, setContentWithoutResults] = useState([]); + + const contentData = useMemo( + () => ({ + [CONTENT_TYPE_COURSE]: { + filter: courseFilter, + noResults: noCourseResults, + setNoResults: setNoCourseResults, + numResults: NUM_RESULTS_COURSE, + }, + [EXECUTIVE_EDUCATION_2U_COURSE_TYPE]: { + filter: execEdFilter, + noResults: noExecEdResults, + setNoResults: setNoExecEdResults, + numResults: NUM_RESULTS_COURSE, + }, + [CONTENT_TYPE_PROGRAM]: { + filter: programFilter, + noResults: noProgramResults, + setNoResults: setNoProgramResults, + numResults: NUM_RESULTS_PROGRAM, + }, + }), + [ + courseFilter, + execEdFilter, + programFilter, + noCourseResults, + noExecEdResults, + noProgramResults, + ], + ); + + useEffect(() => { + contentData[CONTENT_TYPE_COURSE].noResults = noCourseResults; + contentData[CONTENT_TYPE_PROGRAM].noResults = noProgramResults; + contentData[EXECUTIVE_EDUCATION_2U_COURSE_TYPE].noResults = noExecEdResults; + }, [noCourseResults, noProgramResults, noExecEdResults, contentData]); - let suggestedSearchContentTypeFilter; - if (!contentType || contentType.length === 2) { - suggestedSearchContentTypeFilter = combinedFilter; - } else if (specifiedContentType === CONTENT_TYPE_PROGRAM) { - suggestedSearchContentTypeFilter = programFilter; - } else { - suggestedSearchContentTypeFilter = courseFilter; - } + // set specified content types & suggested search content types + useEffect(() => { + if (learningType) { + if (learningType.length === 1) { + setSpecifiedContentType(learningType); + } + setSuggestedSearchContentTypeFilter( + learningType.map((item) => `learning_type:${item}`).join(' OR '), + ); + } else { + setSpecifiedContentType(undefined); + setSuggestedSearchContentTypeFilter( + [ + CONTENT_TYPE_COURSE, + CONTENT_TYPE_PROGRAM, + EXECUTIVE_EDUCATION_2U_COURSE_TYPE, + ] + .map((item) => `learning_type:${item}`) + .join(' OR '), + ); + } + }, [learningType, setSpecifiedContentType]); const config = getConfig(); const courseIndex = useMemo(() => { @@ -67,15 +127,102 @@ function CatalogSearch(intl) { }, [config.ALGOLIA_INDEX_NAME, searchClient]); const suggestedCourseOnClick = (hit) => { - if (hit.program_type !== undefined) { + if (hit.learning_type === CONTENT_TYPE_PROGRAM) { setSelectedSuggestedCourse(mapAlgoliaObjectToProgram(hit)); - setSelectedSuggestedCourseType('program'); + setSelectedSuggestedCourseType(CONTENT_TYPE_PROGRAM); + } else if (hit.learning_type === EXECUTIVE_EDUCATION_2U_COURSE_TYPE) { + setSelectedSuggestedCourse(mapAlgoliaObjectToExecEd(hit)); + setSelectedSuggestedCourseType(EXECUTIVE_EDUCATION_2U_COURSE_TYPE); } else { setSelectedSuggestedCourse(mapAlgoliaObjectToCourse(hit, intl, messages)); - setSelectedSuggestedCourseType('course'); + setSelectedSuggestedCourseType(CONTENT_TYPE_COURSE); + } + }; + + // Build out the list of content to display, ordering first by learning types that currently have results + // and then by `course` > `exec ed` > `program`. Make sure to remove exec ed if the feature flag is disabled + // or if the currently selected catalog isn't `a la carte`. + useEffect(() => { + const defaultTypes = [ + CONTENT_TYPE_COURSE, + EXECUTIVE_EDUCATION_2U_COURSE_TYPE, + CONTENT_TYPE_PROGRAM, + ]; + // Grab content type(s) to use + const contentToDisplay = learningType + ? defaultTypes.filter((value) => learningType.includes(value)) + : defaultTypes; + + // Determine if we need to remove exec ed + if ( + (enterpriseCatalogQueryTitles + && !enterpriseCatalogQueryTitles.includes( + config.EDX_ENTERPRISE_ALACARTE_TITLE, + )) + || !features.EXEC_ED_INCLUSION + ) { + if (contentToDisplay.indexOf(EXECUTIVE_EDUCATION_2U_COURSE_TYPE) > 0) { + contentToDisplay.splice( + contentToDisplay.indexOf(EXECUTIVE_EDUCATION_2U_COURSE_TYPE), + 1, + ); + } } + + // rendering in order of what content we have search results for + const resultList = contentToDisplay.filter( + (obj) => !contentData[obj].noResults, + ); + setContentWithResults(resultList); + + const noResultList = contentToDisplay.filter( + (obj) => contentData[obj].noResults, + ); + setContentWithoutResults(noResultList); + }, [ + enterpriseCatalogQueryTitles, + config, + specifiedContentType, + learningType, + contentData, + noCourseResults, + noProgramResults, + noExecEdResults, + ]); + + // Take a list of learning types and render a search results component for each item + const contentToRender = (items) => { + const itemsWithResultsList = items.map((item) => ( + + + + + )); + return itemsWithResultsList; }; + const defaultInstantSearchFilter = `learning_type:${CONTENT_TYPE_COURSE} OR learning_type:${CONTENT_TYPE_PROGRAM}${ + features.EXEC_ED_INCLUSION + ? ` OR learning_type:${EXECUTIVE_EDUCATION_2U_COURSE_TYPE}` + : '' + }`; + return (
@@ -88,7 +235,7 @@ function CatalogSearch(intl) {
<> - {(!contentType || contentType.length === 2) - && (noCourseResults === noProgramResults || !noCourseResults) && ( - <> - - - - - - - - - - )} - {(!contentType || contentType.length === 2) - && noCourseResults - && !noProgramResults && ( - <> - - - - - - - - - - )} - {specifiedContentType === CONTENT_TYPE_PROGRAM && ( - - - - - )} - {specifiedContentType === CONTENT_TYPE_COURSE && ( - - - - - )} + {contentToRender([...contentWithResults, ...contentWithoutResults])}
diff --git a/src/components/catalogs/CatalogSearch.test.jsx b/src/components/catalogs/CatalogSearch.test.jsx index ea8b6751..69b9e4a4 100644 --- a/src/components/catalogs/CatalogSearch.test.jsx +++ b/src/components/catalogs/CatalogSearch.test.jsx @@ -18,10 +18,13 @@ jest.mock('react-instantsearch-dom', () => ({ const DEFAULT_SEARCH_CONTEXT_VALUE = { refinements: {} }; const COURSE_SEARCH_CONTEXT_VALUE = { - refinements: { content_type: ['course'] }, + refinements: { learning_type: ['course'] }, }; const PROGRAM_SEARCH_CONTEXT_VALUE = { - refinements: { content_type: ['program'] }, + refinements: { learning_type: ['program'] }, +}; +const EXEC_ED_COURSE_SEARCH_CONTEXT_VALUE = { + refinements: { learning_type: ['executive-education-2u'] }, }; // eslint-disable-next-line react/prop-types @@ -64,4 +67,12 @@ describe('Catalog Search component', () => { ); expect(screen.getByText('SEARCH')).toBeInTheDocument(); }); + it('properly renders component with exec ed course content type context', () => { + renderWithRouter( + + + , + ); + expect(screen.getByText('SEARCH')).toBeInTheDocument(); + }); }); diff --git a/src/components/courseCard/CourseCard.jsx b/src/components/courseCard/CourseCard.jsx index b0c09017..f2f9d0a0 100644 --- a/src/components/courseCard/CourseCard.jsx +++ b/src/components/courseCard/CourseCard.jsx @@ -6,19 +6,32 @@ import PropTypes from 'prop-types'; import { Badge, Card } from '@edx/paragon'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import messages from './CourseCard.messages'; +import { CONTENT_TYPE_COURSE } from '../../constants'; import defaultCardHeader from '../../static/default-card-header-light.png'; -function CourseCard({ intl, onClick, original }) { +function CourseCard({ + intl, onClick, original, learningType, +}) { const { title, card_image_url, partners, first_enrollable_paid_seat_price, enterprise_catalog_query_titles, - availability, + entitlements, + advertised_course_run, } = original; - const rowPrice = first_enrollable_paid_seat_price; - const priceText = rowPrice != null ? `$${rowPrice.toString()}` : 'N/A'; + let rowPrice; + let priceText; + + if (learningType === CONTENT_TYPE_COURSE) { + rowPrice = first_enrollable_paid_seat_price; + priceText = rowPrice != null ? `$${rowPrice.toString()}` : 'N/A'; + } else { + [rowPrice] = entitlements || [null]; + priceText = rowPrice != null ? `$${rowPrice.price?.toString()}` : 'N/A'; + } + const imageSrc = card_image_url || defaultCardHeader; const altText = `${title} course image`; @@ -39,7 +52,7 @@ function CourseCard({ intl, onClick, original }) {

- {priceText} • {availability[0]} + {priceText} • {advertised_course_run ? advertised_course_run.pacing_type?.replace('_', ' ') : 'NA'}

{enterprise_catalog_query_titles.includes( @@ -79,9 +92,12 @@ CourseCard.defaultProps = { CourseCard.propTypes = { intl: intlShape.isRequired, onClick: PropTypes.func, + learningType: PropTypes.string.isRequired, original: PropTypes.shape({ title: PropTypes.string, card_image_url: PropTypes.string, + entitlements: PropTypes.arrayOf(PropTypes.shape()), + advertised_course_run: PropTypes.shape(), partners: PropTypes.arrayOf( PropTypes.shape({ name: PropTypes.string, @@ -91,7 +107,6 @@ CourseCard.propTypes = { first_enrollable_paid_seat_price: PropTypes.number, enterprise_catalog_query_titles: PropTypes.arrayOf(PropTypes.string), original_image_url: PropTypes.string, - availability: PropTypes.arrayOf(PropTypes.string), }).isRequired, }; diff --git a/src/components/courseCard/CourseCard.test.jsx b/src/components/courseCard/CourseCard.test.jsx index 1db127d5..4a382d0d 100644 --- a/src/components/courseCard/CourseCard.test.jsx +++ b/src/components/courseCard/CourseCard.test.jsx @@ -4,6 +4,7 @@ import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import CourseCard from './CourseCard'; +import { CONTENT_TYPE_COURSE, EXECUTIVE_EDUCATION_2U_COURSE_TYPE } from '../../constants'; jest.mock('@edx/frontend-platform', () => ({ ...jest.requireActual('@edx/frontend-platform'), @@ -18,11 +19,28 @@ const originalData = { first_enrollable_paid_seat_price: 100, original_image_url: '', enterprise_catalog_query_titles: TEST_CATALOG, - availability: ['Available Now'], + advertised_course_run: { pacing_type: 'self_paced' }, }; const defaultProps = { original: originalData, + learningType: CONTENT_TYPE_COURSE, +}; + +const execEdData = { + title: 'Course Title', + card_image_url: undefined, + partners: [{ logo_image_url: '', name: 'Course Provider' }], + first_enrollable_paid_seat_price: 100, + original_image_url: '', + enterprise_catalog_query_titles: TEST_CATALOG, + advertised_course_run: { pacing_type: 'instructor_paced' }, + entitlements: [{ price: '999' }], +}; + +const execEdProps = { + original: execEdData, + learningType: EXECUTIVE_EDUCATION_2U_COURSE_TYPE, }; describe('Course card works as expected', () => { @@ -39,7 +57,7 @@ describe('Course card works as expected', () => { expect( screen.queryByText(defaultProps.original.partners[0].name), ).toBeInTheDocument(); - expect(screen.queryByText('$100 • Available Now')).toBeInTheDocument(); + expect(screen.queryByText('$100 • self paced')).toBeInTheDocument(); expect(screen.queryByText('Business')).toBeInTheDocument(); }); test('test card renders default image', async () => { @@ -52,4 +70,17 @@ describe('Course card works as expected', () => { fireEvent.error(screen.getByAltText(imageAltText)); await expect(screen.getByAltText(imageAltText).src).not.toBeUndefined; }); + test('exec ed card renders correct price from entitlement', async () => { + process.env.EDX_FOR_BUSINESS_TITLE = 'ayylmao'; + process.env.EDX_FOR_ONLINE_EDU_TITLE = 'foo'; + process.env.EDX_ENTERPRISE_ALACARTE_TITLE = 'baz'; + render( + + + , + ); + expect(screen.queryByText(execEdProps.original.title)).toBeInTheDocument(); + expect(screen.queryByText('$999 • instructor paced')).toBeInTheDocument(); + expect(screen.queryByText('Business')).toBeInTheDocument(); + }); }); diff --git a/src/config/constants.js b/src/config/constants.js index 527d3000..5d43734a 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -1,2 +1,3 @@ export const FEATURE_ENABLE_PROGRAMS = 'ENABLE_PROGRAMS'; export const FEATURE_PROGRAM_TYPE_FACET = 'ENABLE_PROGRAM_TYPE_FACET'; +export const FEATURE_EXEC_ED_INCLUSION = 'ENABLE_EXEC_ED_INCLUSION'; diff --git a/src/config/index.js b/src/config/index.js index dceea83d..b13aebc9 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -1,6 +1,7 @@ import qs from 'query-string'; import { FEATURE_ENABLE_PROGRAMS, + FEATURE_EXEC_ED_INCLUSION, FEATURE_PROGRAM_TYPE_FACET, } from './constants'; @@ -16,6 +17,9 @@ const features = { PROGRAM_TYPE_FACET: process.env.FEATURE_PROGRAM_TYPE_FACET === 'true' || hasFeatureFlagEnabled(FEATURE_PROGRAM_TYPE_FACET), + EXEC_ED_INCLUSION: + process.env.EXEC_ED_INCLUSION === 'true' + || hasFeatureFlagEnabled(FEATURE_EXEC_ED_INCLUSION), }; export default features; diff --git a/src/constants.js b/src/constants.js index 4088c5a7..32f1a88b 100644 --- a/src/constants.js +++ b/src/constants.js @@ -17,7 +17,10 @@ export const AVAILABILITY_REFINEMENT_DEFAULTS = [ 'Starting Soon', 'Upcoming', ]; + export const CONTENT_TYPE_REFINEMENT = 'content_type'; +export const COURSE_TYPE_REFINEMENT = 'course_type'; +export const LEARNING_TYPE_REFINEMENT = 'learning_type'; export const HIDE_CARDS_REFINEMENT = 'hide_cards'; export const HIDE_PRICE_REFINEMENT = 'hide_price'; export const NUM_RESULTS_PER_PAGE = 40; @@ -27,6 +30,7 @@ export const NUM_RESULTS_PROGRAM = 4; export const NUM_RESULTS_COURSE = 8; export const COURSE_TITLE = 'Courses'; export const PROGRAM_TITLE = 'Programs'; +export const EXEC_ED_TITLE = 'Executive Education'; export const NO_RESULTS_DECK_ITEM_COUNT = 4; export const NO_RESULTS_PAGE_ITEM_COUNT = 1; export const NO_RESULTS_PAGE_SIZE = 4; @@ -35,7 +39,6 @@ const VERIFIED_AUDIT_COURSE_TYPE = 'verified-audit'; const PROFESSIONAL_COURSE_TYPE = 'professional'; const CREDIT_VERIFIED_AUDIT_COURSE_TYPE = 'credit-verified-audit'; export const EXECUTIVE_EDUCATION_2U_COURSE_TYPE = 'executive-education-2u'; -export const TWOU_COURSE_TYPES = [EXECUTIVE_EDUCATION_2U_COURSE_TYPE]; export const EDX_COURSES_COURSE_TYPES = [ AUDIT_COURSE_TYPE, VERIFIED_AUDIT_COURSE_TYPE, @@ -43,6 +46,10 @@ export const EDX_COURSES_COURSE_TYPES = [ CREDIT_VERIFIED_AUDIT_COURSE_TYPE, ]; +export const EDX_COURSE_TITLE_DESC = 'Self paced online learning from world-class academic institutions and corporate partners.'; +export const TWOU_EXEC_ED_TITLE_DESC = 'Immersive, instructor led online short courses designed to develop interpersonal, analytical, and critical thinking skills.'; +export const PROGRAM_TITLE_DESC = 'Multi-course bundled learning for skills mastery and to earn credentials such as Professional Certificates, MicroBachelors™, MicroMasters®, and Master’s Degrees.'; + const OVERRIDE_FACET_FILTERS = []; if (features.PROGRAM_TYPE_FACET) { const PROGRAM_TYPE_FACET_OVERRIDE = { diff --git a/src/utils/algoliaUtils.js b/src/utils/algoliaUtils.js index 423e5979..6bc0f64c 100644 --- a/src/utils/algoliaUtils.js +++ b/src/utils/algoliaUtils.js @@ -42,7 +42,46 @@ function mapAlgoliaObjectToCourse(algoliaCourseObject, intl, messages) { } /** - * Converts and algolia course object, into a course representation usable in this UI + * Converts an algolia exec ed course object, into a course representation usable in this UI + */ +function mapAlgoliaObjectToExecEd(algoliaCourseObject, intl, messages) { + const { + title: courseTitle, + partners, + entitlements: coursePrice, // todo + enterprise_catalog_query_titles: courseAssociatedCatalogs, + full_description: courseDescription, + original_image_url: bannerImageUrl, + marketing_url: marketingUrl, + advertised_course_run: courseRun, + upcoming_course_runs: upcomingRuns, + skill_names: skillNames, + } = algoliaCourseObject; + const { start: startDate, end: endDate } = courseRun; + const priceText = coursePrice != null + ? `$${coursePrice[0].price.toString()}` + : intl.formatMessage( + messages['catalogSearchResult.table.priceNotAvailable'], + ); + return { + contentType: CONTENT_TYPE_COURSE, + courseTitle, + courseProvider: partners[0].name, + coursePrice: priceText, + courseAssociatedCatalogs, + courseDescription, + partnerLogoImageUrl: partners[0].logo_image_url, + bannerImageUrl, + marketingUrl, + startDate, + endDate, + upcomingRuns, + skillNames, + }; +} + +/** + * Converts an algolia course object, into a course representation usable in this UI */ function mapAlgoliaObjectToProgram(algoliaProgramObject) { const { @@ -75,4 +114,6 @@ function mapAlgoliaObjectToProgram(algoliaProgramObject) { }; } -export { mapAlgoliaObjectToProgram, mapAlgoliaObjectToCourse, extractUuid }; +export { + mapAlgoliaObjectToProgram, mapAlgoliaObjectToExecEd, mapAlgoliaObjectToCourse, extractUuid, +};