From 017f3490bbb2f7dce34ca3e3341a6c4393ed25be Mon Sep 17 00:00:00 2001 From: Katrina Nguyen <71999631+katrinan029@users.noreply.github.com> Date: Fri, 27 Oct 2023 14:53:14 -0700 Subject: [PATCH] feat: display search result cards in catalog tab (#1059) * feat: display search result cards in catalog tab * fix: failing test in BudgetDetailPage * fix: replace word register with enroll * fix: implemented reviewer comments * fix: lint error * fix: lint error * feat: added policy's catalog uuid to search filter * fix: failing test * fix: refactored based on reviewer feedback * fix: lint error * fix: refactored code to include new api field and updated test * fix: removing unused prop in test * fix: refactored * fix: search filters * chore: refactored --- .../BudgetDetailCatalogTabContents.jsx | 14 +- .../cards/CourseCard.jsx | 152 +++++++++++------- .../cards/CourseCard.test.jsx | 71 ++++---- .../learner-credit-management/constants.js | 18 +++ .../data/constants.js | 8 +- .../learner-credit-management/data/utils.js | 11 ++ .../learner-credit-management/index.jsx | 1 - .../learner-credit.scss | 25 --- .../search/CatalogSearch.jsx | 14 +- .../search/CatalogSearchResults.jsx | 44 ++--- .../tests/CatalogSearchResults.test.jsx | 14 +- 11 files changed, 214 insertions(+), 158 deletions(-) create mode 100644 src/components/learner-credit-management/constants.js delete mode 100644 src/components/learner-credit-management/learner-credit.scss diff --git a/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx b/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx index a99f268db3..a143c1f07a 100644 --- a/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx +++ b/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx @@ -5,10 +5,20 @@ import { Row, Col } from '@edx/paragon'; import { SearchData, SEARCH_FACET_FILTERS } from '@edx/frontend-enterprise-catalog-search'; import CatalogSearch from './search/CatalogSearch'; -import { LANGUAGE_REFINEMENT, LEARNING_TYPE_REFINEMENT } from './data'; +import { + LANGUAGE_REFINEMENT, + LEARNING_TYPE_REFINEMENT, + useBudgetId, + useSubsidyAccessPolicy, +} from './data'; import { configuration } from '../../config'; const BudgetDetailCatalogTabContents = () => { + const { subsidyAccessPolicyId } = useBudgetId(); + const { + data: subsidyAccessPolicy, + } = useSubsidyAccessPolicy(subsidyAccessPolicyId); + const language = { attribute: LANGUAGE_REFINEMENT, title: 'Language', @@ -38,7 +48,7 @@ const BudgetDetailCatalogTabContents = () => { indexName={configuration.ALGOLIA.INDEX_NAME} searchClient={searchClient} > - + diff --git a/src/components/learner-credit-management/cards/CourseCard.jsx b/src/components/learner-credit-management/cards/CourseCard.jsx index 9284369239..f4885ce433 100644 --- a/src/components/learner-credit-management/cards/CourseCard.jsx +++ b/src/components/learner-credit-management/cards/CourseCard.jsx @@ -3,95 +3,137 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { camelCaseObject } from '@edx/frontend-platform'; -import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png'; import { - Badge, Button, Card, Hyperlink, + Badge, + Button, + Card, + Stack, + Hyperlink, + useMediaQuery, + breakpoints, } from '@edx/paragon'; -import { EXEC_COURSE_TYPE } from '../data/constants'; -import { formatDate } from '../data/utils'; +import { injectIntl } from '@edx/frontend-platform/i18n'; +import { camelCaseObject } from '@edx/frontend-platform'; +import cardFallbackImg from '@edx/brand/paragon/images/card-imagecap-fallback.png'; + +import { EXEC_ED_COURSE_TYPE } from '../data'; +import { formatPrice, formatDate, getEnrollmentDeadline } from '../data/utils'; +import CARD_TEXT from '../constants'; const CourseCard = ({ - onClick, original, + original, }) => { const { - title, + availability, cardImageUrl, courseType, normalizedMetadata, partners, + title, } = camelCaseObject(original); - let priceText; + const isSmall = useMediaQuery({ maxWidth: breakpoints.small.maxWidth }); + const isExtraSmall = useMediaQuery({ maxWidth: breakpoints.extraSmall.maxWidth }); + + const { + BADGE, + BUTTON_ACTION, + PRICE, + ENROLLMENT, + } = CARD_TEXT; + + const price = normalizedMetadata?.contentPrice ? formatPrice(normalizedMetadata.contentPrice, { minimumFractionDigits: 0 }) : 'N/A'; + + const imageSrc = cardImageUrl || cardFallbackImg; + + let logoSrc; + let logoAlt; + if (partners.length === 1) { + logoSrc = partners[0]?.logoImageUrl; + logoAlt = `${partners[0]?.name}'s logo`; + } + const altText = `${title} course image`; + const formattedAvailability = availability?.length ? availability.join(', ') : null; + + const enrollmentDeadline = getEnrollmentDeadline(normalizedMetadata?.enrollByDate); + + let courseEnrollmentInfo; + let execEdEnrollmentInfo; + if (normalizedMetadata?.enrollByDate) { + courseEnrollmentInfo = `${formattedAvailability} • ${ENROLLMENT.text} ${enrollmentDeadline}`; + execEdEnrollmentInfo = `Starts ${formatDate(normalizedMetadata.startDate)} • + ${ENROLLMENT.text} ${enrollmentDeadline}`; + } else { + courseEnrollmentInfo = formattedAvailability; + execEdEnrollmentInfo = formattedAvailability; + } + + const isExecEd = courseType === EXEC_ED_COURSE_TYPE; + return ( onClick(original)} - orientation="horizontal" - tabIndex="0" + orientation={isSmall ? 'vertical' : 'horizontal'} > -
-
-

{title}

-

{partners[0]?.name}

- {courseType === EXEC_COURSE_TYPE && ( - - Executive Education - - )} - {courseType !== EXEC_COURSE_TYPE && ( -

+ + +

{price}

+ {PRICE.subText} + )} -

- Starts {formatDate(normalizedMetadata?.start_date)} • - Learner must register by {formatDate(normalizedMetadata?.enroll_by_date)} -

-
- -

{priceText}

-

Per learner price

- - - - - + /> + + + {isExecEd ? BADGE.execEd : BADGE.course} + -
+ + + + +
); }; -CourseCard.defaultProps = { - onClick: () => {}, -}; - CourseCard.propTypes = { - onClick: PropTypes.func, original: PropTypes.shape({ - title: PropTypes.string, + availability: PropTypes.arrayOf(PropTypes.string), cardImageUrl: PropTypes.string, + courseType: PropTypes.string, + normalizedMetadata: PropTypes.shape(), + originalImageUrl: PropTypes.string, partners: PropTypes.arrayOf( PropTypes.shape({ + logoImageUrl: PropTypes.string, name: PropTypes.string, - logo_image_url: PropTypes.string, }), ), - normalizedMetadata: PropTypes.shape({ - startDate: PropTypes.string, - endDate: PropTypes.string, - enrollByDate: PropTypes.string, - }), - courseType: PropTypes.string, + title: PropTypes.string, }).isRequired, }; -export default CourseCard; +export default injectIntl(CourseCard); diff --git a/src/components/learner-credit-management/cards/CourseCard.test.jsx b/src/components/learner-credit-management/cards/CourseCard.test.jsx index 963137e178..704a1f825c 100644 --- a/src/components/learner-credit-management/cards/CourseCard.test.jsx +++ b/src/components/learner-credit-management/cards/CourseCard.test.jsx @@ -4,47 +4,46 @@ import '@testing-library/jest-dom/extend-expect'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import CourseCard from './CourseCard'; -import { CONTENT_TYPE_COURSE, EXEC_ED_TITLE } from '../data/constants'; - -jest.mock('@edx/frontend-platform', () => ({ - ...jest.requireActual('@edx/frontend-platform'), -})); - -const TEST_CATALOG = ['ayylmao']; const originalData = { - title: 'Course Title', + availability: ['Upcoming'], card_image_url: undefined, - partners: [{ logo_image_url: '', name: 'Course Provider' }], - first_enrollable_paid_seat_price: 100, + course_type: 'course', + normalized_metadata: { + enroll_by_date: '2016-02-18T04:00:00Z', + start_date: '2016-04-18T04:00:00Z', + content_price: 100, + }, original_image_url: '', - enterprise_catalog_query_titles: TEST_CATALOG, - advertised_course_run: { pacing_type: 'self_paced' }, + partners: [{ logo_image_url: '', name: 'Course Provider' }], + title: 'Course Title', }; const defaultProps = { original: originalData, - learningType: CONTENT_TYPE_COURSE, }; const execEdData = { - title: 'Exec Ed Course Title', + availability: ['Upcoming'], 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' }, + course_type: 'executive-education-2u', entitlements: [{ price: '999.00' }], + normalized_metadata: { + enroll_by_date: '2016-02-18T04:00:00Z', + start_date: '2016-04-18T04:00:00Z', + content_price: 999, + }, + original_image_url: '', + partners: [{ logo_image_url: '', name: 'Course Provider' }], + title: 'Exec Ed Title', }; const execEdProps = { original: execEdData, - learningType: EXEC_ED_TITLE, }; describe('Course card works as expected', () => { - test('card renders as expected', () => { + test('course card renders', () => { render( @@ -54,21 +53,14 @@ describe('Course card works as expected', () => { expect( screen.queryByText(defaultProps.original.partners[0].name), ).toBeInTheDocument(); - expect(screen.queryByText('Course Title')).toBeInTheDocument(); + expect(screen.queryByText('$100')).toBeInTheDocument(); expect(screen.queryByText('Per learner price')).toBeInTheDocument(); + expect(screen.queryByText('Upcoming • Learner must enroll by Feb 18, 2016')).toBeInTheDocument(); + expect(screen.queryByText('Course')).toBeInTheDocument(); + expect(screen.queryByText('View course')).toBeInTheDocument(); + expect(screen.queryByText('Assign')).toBeInTheDocument(); }); - test('exec ed card renders as expected', () => { - render( - - - , - ); - expect(screen.queryByText(execEdProps.original.title)).toBeInTheDocument(); - expect( - screen.queryByText(execEdProps.original.partners[0].name), - ).toBeInTheDocument(); - expect(screen.queryByText('Exec Ed Course Title')).toBeInTheDocument(); - }); + test('test card renders default image', async () => { render( @@ -79,4 +71,15 @@ describe('Course card works as expected', () => { fireEvent.error(screen.getByAltText(imageAltText)); await expect(screen.getByAltText(imageAltText).src).not.toBeUndefined; }); + + test('exec ed card renders', async () => { + render( + + + , + ); + expect(screen.queryByText('$999')).toBeInTheDocument(); + expect(screen.queryByText('Starts Apr 18, 2016 • Learner must enroll by Feb 18, 2016')).toBeInTheDocument(); + expect(screen.queryByText('Executive Education')).toBeInTheDocument(); + }); }); diff --git a/src/components/learner-credit-management/constants.js b/src/components/learner-credit-management/constants.js new file mode 100644 index 0000000000..7148a852d2 --- /dev/null +++ b/src/components/learner-credit-management/constants.js @@ -0,0 +1,18 @@ +const CARD_TEXT = { + BADGE: { + course: 'Course', + execEd: 'Executive Education', + }, + BUTTON_ACTION: { + viewCourse: 'View course', + assign: 'Assign', + }, + ENROLLMENT: { + text: 'Learner must enroll by', + }, + PRICE: { + subText: 'Per learner price', + }, +}; + +export default CARD_TEXT; diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index 8a93ee6f6d..08ad16ef5d 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -32,12 +32,18 @@ export const LANGUAGE_REFINEMENT = 'language'; // Learning types export const CONTENT_TYPE_COURSE = 'course'; export const EXEC_ED_TITLE = 'Executive Education'; -export const EXEC_COURSE_TYPE = 'executive-education-2u'; +export const EXEC_ED_COURSE_TYPE = 'executive-education-2u'; + +// Learner must enroll within 90 days of assignment +export const ASSIGNMENT_ENROLLMENT_DEADLINE = 90; // Number of items to display per page in Budget Detail assignment/spend tables export const PAGE_SIZE = 25; export const DEFAULT_PAGE = 0; // `DataTable` uses zero-index array +// Number of items to display per page in Budget Catalog tab +export const SEARCH_RESULT_PAGE_SIZE = 15; + // Query Key factory for the learner credit management module, intended to be used with `@tanstack/react-query`. // Inspired by https://tkdodo.eu/blog/effective-react-query-keys#use-query-key-factories. export const learnerCreditManagementQueryKeys = { diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index 9874c22f4b..6a5a45888c 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -5,6 +5,7 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { LOW_REMAINING_BALANCE_PERCENT_THRESHOLD, NO_BALANCE_REMAINING_DOLLAR_THRESHOLD, + ASSIGNMENT_ENROLLMENT_DEADLINE, } from './constants'; import { BUDGET_STATUSES } from '../../EnterpriseApp/data/constants'; import EnterpriseAccessApiService from '../../../data/services/EnterpriseAccessApiService'; @@ -199,6 +200,16 @@ export function formatDate(date) { return dayjs(date).format('MMM D, YYYY'); } +// Exec ed and open courses cards should display either the enrollment deadline +// or 90 days from the present date on user pageload, whichever is sooner. +export function getEnrollmentDeadline(enrollByDate) { + const courseEnrollByDate = dayjs(enrollByDate); + const assignmentEnrollmentDeadline = dayjs().add(ASSIGNMENT_ENROLLMENT_DEADLINE, 'days'); + + return courseEnrollByDate <= assignmentEnrollmentDeadline + ? formatDate(courseEnrollByDate) + : formatDate(assignmentEnrollmentDeadline); +} /** * Retrieves content assignments for the given budget's assignment configuration UUID (retrieved from the associated * subsidy access policy). diff --git a/src/components/learner-credit-management/index.jsx b/src/components/learner-credit-management/index.jsx index eb68ef0a21..43786e8a0b 100644 --- a/src/components/learner-credit-management/index.jsx +++ b/src/components/learner-credit-management/index.jsx @@ -2,7 +2,6 @@ import React from 'react'; import { Route } from 'react-router-dom'; import PropTypes from 'prop-types'; import MultipleBudgetsPage from './MultipleBudgetsPage'; -import './learner-credit.scss'; import BudgetDetailPage from './BudgetDetailPage'; const LearnerCreditManagementRoutes = ({ match }) => ( diff --git a/src/components/learner-credit-management/learner-credit.scss b/src/components/learner-credit-management/learner-credit.scss deleted file mode 100644 index c26c3e9859..0000000000 --- a/src/components/learner-credit-management/learner-credit.scss +++ /dev/null @@ -1,25 +0,0 @@ -.card-container { - display: flex; - padding: 1rem; - flex-grow: 1; - justify-content: space-between; - - .section-1 { - flex-direction: column; - } - .section-2 { - margin-left: 0; - text-align: end !important; - min-width: 400px; - padding-right: 0; - justify-content: space-between; - .footer { - justify-content: end; - padding: 0; - } - } -} - -.badge { - margin: 4px; -} diff --git a/src/components/learner-credit-management/search/CatalogSearch.jsx b/src/components/learner-credit-management/search/CatalogSearch.jsx index c19d87dc50..d32fa3015a 100644 --- a/src/components/learner-credit-management/search/CatalogSearch.jsx +++ b/src/components/learner-credit-management/search/CatalogSearch.jsx @@ -1,19 +1,18 @@ import React from 'react'; -import { useParams } from 'react-router-dom'; import algoliasearch from 'algoliasearch/lite'; import { Configure, InstantSearch } from 'react-instantsearch-dom'; +import PropTypes from 'prop-types'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { SearchHeader } from '@edx/frontend-enterprise-catalog-search'; import { configuration } from '../../../config'; import CatalogSearchResults from './CatalogSearchResults'; +import { SEARCH_RESULT_PAGE_SIZE } from '../data'; -const CatalogSearch = () => { - const { budgetId } = useParams(); +const CatalogSearch = ({ catalogUuid }) => { const searchClient = algoliasearch(configuration.ALGOLIA.APP_ID, configuration.ALGOLIA.SEARCH_API_KEY); - - const searchFilters = `enterprise_catalog_query_uuids:${budgetId}`; + const searchFilters = `enterprise_catalog_uuids:${catalogUuid} AND content_type:course`; return (
@@ -28,6 +27,7 @@ const CatalogSearch = () => { { ); }; +CatalogSearch.propTypes = { + catalogUuid: PropTypes.string.isRequired, +}; + export default CatalogSearch; diff --git a/src/components/learner-credit-management/search/CatalogSearchResults.jsx b/src/components/learner-credit-management/search/CatalogSearchResults.jsx index 5127568653..44faafff34 100644 --- a/src/components/learner-credit-management/search/CatalogSearchResults.jsx +++ b/src/components/learner-credit-management/search/CatalogSearchResults.jsx @@ -1,19 +1,18 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useContext, useEffect, useMemo } from 'react'; import { connectStateResults } from 'react-instantsearch-dom'; import PropTypes from 'prop-types'; -import { SearchPagination } from '@edx/frontend-enterprise-catalog-search'; +import { SearchPagination, SearchContext } from '@edx/frontend-enterprise-catalog-search'; import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; import { - Alert, CardView, DataTable, Skeleton, + Alert, CardView, DataTable, TextFilter, } from '@edx/paragon'; import CourseCard from '../cards/CourseCard'; +import { SEARCH_RESULT_PAGE_SIZE } from '../data'; export const ERROR_MESSAGE = 'An error occurred while retrieving data'; -export const SKELETON_DATA_TESTID = 'enterprise-catalog-skeleton'; - /** * The core search results rendering component. * @@ -27,8 +26,10 @@ export const SKELETON_DATA_TESTID = 'enterprise-catalog-skeleton'; export const BaseCatalogSearchResults = ({ searchResults, + searchState, // algolia recommends this prop instead of searching isSearchStalled, + paginationComponent: PaginationComponent, error, setNoContent, }) => { @@ -58,20 +59,13 @@ export const BaseCatalogSearchResults = ({ () => searchResults?.hits || [], [searchResults?.hits], ); - - const renderCardComponent = (props) => ; + const { refinements } = useContext(SearchContext); + const page = refinements.page || (searchState.page || 0); useEffect(() => { setNoContent(searchResults === null || searchResults?.nbHits === 0); }, [searchResults, setNoContent]); - if (isSearchStalled) { - return ( -
- -
- ); - } if (error) { return ( @@ -88,21 +82,29 @@ export const BaseCatalogSearchResults = ({ return (
renderCardComponent(props)} + CardComponent={CourseCard} /> - + + +
); @@ -112,7 +114,6 @@ BaseCatalogSearchResults.defaultProps = { searchResults: { disjunctiveFacetsRefinements: [], nbHits: 0, hits: [] }, error: null, paginationComponent: SearchPagination, - preview: false, setNoContent: () => {}, }; @@ -137,7 +138,6 @@ BaseCatalogSearchResults.propTypes = { page: PropTypes.number, }).isRequired, paginationComponent: PropTypes.func, - preview: PropTypes.bool, setNoContent: PropTypes.func, }; diff --git a/src/components/learner-credit-management/tests/CatalogSearchResults.test.jsx b/src/components/learner-credit-management/tests/CatalogSearchResults.test.jsx index 34a75e3ac0..3cbd296449 100644 --- a/src/components/learner-credit-management/tests/CatalogSearchResults.test.jsx +++ b/src/components/learner-credit-management/tests/CatalogSearchResults.test.jsx @@ -5,9 +5,7 @@ import '@testing-library/jest-dom/extend-expect'; import { SearchContext } from '@edx/frontend-enterprise-catalog-search'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { BaseCatalogSearchResults, SKELETON_DATA_TESTID } from '../search/CatalogSearchResults'; - -import { renderWithRouter } from '../../test/testUtils'; +import { BaseCatalogSearchResults } from '../search/CatalogSearchResults'; import { CONTENT_TYPE_COURSE } from '../data/constants'; @@ -141,14 +139,4 @@ describe('Main Catalogs view works as expected', () => { expect(screen.queryByText(TEST_COURSE_NAME_2)).toBeInTheDocument(); expect(screen.getAllByText('Showing 2 of 2.')[0]).toBeInTheDocument(); }); - test('isSearchStalled leads to rendering skeleton and not content', () => { - renderWithRouter( - - - , - ); - expect(screen.queryByRole('alert')).not.toBeInTheDocument(); - expect(screen.queryByText(TEST_COURSE_NAME)).not.toBeInTheDocument(); - expect(screen.getByTestId(SKELETON_DATA_TESTID)).toBeInTheDocument(); - }); });