diff --git a/__mocks__/react-instantsearch-dom.jsx b/__mocks__/react-instantsearch-dom.jsx index f62f6554ca..4444b52c33 100644 --- a/__mocks__/react-instantsearch-dom.jsx +++ b/__mocks__/react-instantsearch-dom.jsx @@ -15,8 +15,8 @@ const advertised_course_run = { /* eslint-disable camelcase */ const fakeHits = [ - { objectID: '1', aggregation_key: 'course:Bees101', title: 'bla', advertised_course_run, key: 'Bees101' }, - { objectID: '2', aggregation_key: 'course:Wasps200', title: 'blp', advertised_course_run, key: 'Wasps200' }, + { objectID: '1', aggregation_key: 'course:Bees101', title: 'bla', partners: [{ name: 'edX' }, { name: 'another_unused' }], advertised_course_run, key: 'Bees101' }, + { objectID: '2', aggregation_key: 'course:Wasps200', title: 'blp', partners: [{ name: 'edX' }, { name: 'another_unused' }], advertised_course_run, key: 'Wasps200' }, ]; /* eslint-enable camelcase */ diff --git a/src/components/Admin/EmbeddedSubscription.test.jsx b/src/components/Admin/EmbeddedSubscription.test.jsx index 16e36f5f97..5c767330d3 100644 --- a/src/components/Admin/EmbeddedSubscription.test.jsx +++ b/src/components/Admin/EmbeddedSubscription.test.jsx @@ -36,7 +36,6 @@ const defaultAppContext = { }, }; -// eslint-disable-next-line react/prop-types const AppContextProvider = ({ children }) => ( {children} diff --git a/src/components/Admin/SubscriptionDetailPage.test.jsx b/src/components/Admin/SubscriptionDetailPage.test.jsx index 82d6b48369..7287107f1e 100644 --- a/src/components/Admin/SubscriptionDetailPage.test.jsx +++ b/src/components/Admin/SubscriptionDetailPage.test.jsx @@ -46,7 +46,6 @@ const initialSubsidyRequestContextValue = { }, }; -// eslint-disable-next-line react/prop-types const AppContextProvider = ({ children }) => ( {children} diff --git a/src/components/Admin/licenses/LicenseAllocationHeader.test.jsx b/src/components/Admin/licenses/LicenseAllocationHeader.test.jsx index bba9f04e4f..8179fad6d0 100644 --- a/src/components/Admin/licenses/LicenseAllocationHeader.test.jsx +++ b/src/components/Admin/licenses/LicenseAllocationHeader.test.jsx @@ -11,7 +11,6 @@ import { SubsidyRequestsContext } from '../../subsidy-requests'; describe('LicenseAllocationHeader', () => { const mockStore = configureMockStore(); - // eslint-disable-next-line react/prop-types const SubscriptionDetailContextWrapper = ({ children }) => ( // eslint-disable-next-line react/jsx-no-constructed-context-values { ); - // eslint-disable-next-line react/prop-types const SubsidyRequestsContextWrapper = ({ children }) => ( // eslint-disable-next-line react/jsx-no-constructed-context-values ( diff --git a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx index 58a42934cf..b72e8e3dee 100644 --- a/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx +++ b/src/components/BulkEnrollmentPage/stepper/BulkEnrollmentSubmit.test.jsx @@ -85,7 +85,6 @@ const bulkEnrollWithCoursesSelectedRows = { courses: [selectedCourses, coursesDispatch], }; -// eslint-disable-next-line react/prop-types const BulkEnrollmentSubmitWrapper = ({ bulkEnrollInfo = defaultBulkEnrollInfo, ...props }) => ( diff --git a/src/components/CodeAssignmentModal/CodeAssignmentModal.test.jsx b/src/components/CodeAssignmentModal/CodeAssignmentModal.test.jsx index a62e71dc20..b06b247812 100644 --- a/src/components/CodeAssignmentModal/CodeAssignmentModal.test.jsx +++ b/src/components/CodeAssignmentModal/CodeAssignmentModal.test.jsx @@ -20,7 +20,7 @@ import { jest.mock('redux-form', () => ({ ...jest.requireActual('redux-form'), - // eslint-disable-next-line react/prop-types + Field: ({ label, ...rest }) =>
{label}
, })); diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx index 1f8f8f3a7a..b5b836dac8 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentConfirmContentCard.test.jsx @@ -45,7 +45,7 @@ const searchClient = algoliasearch( ); const ContentHighlightContentCardWrapper = ({ - // eslint-disable-next-line react/prop-types + store = mockStore(initialState), }) => { const contextValue = useState({ diff --git a/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperConfirmContent.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperConfirmContent.test.jsx index 68022a1df8..34a9c64a67 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperConfirmContent.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperConfirmContent.test.jsx @@ -30,7 +30,6 @@ const searchClient = algoliasearch( configuration.ALGOLIA.SEARCH_API_KEY, ); -// eslint-disable-next-line react/prop-types const HighlightStepperConfirmContentWrapper = ({ children, currentSelectedRowIds = [] }) => { const contextValue = useState({ stepperModal: { diff --git a/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx index c4f329b011..4d489c6d03 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperSelectContentSearch.test.jsx @@ -38,7 +38,6 @@ const searchClient = algoliasearch( configuration.ALGOLIA.SEARCH_API_KEY, ); -// eslint-disable-next-line react/prop-types const HighlightStepperSelectContentSearchWrapper = ({ children, currentSelectedRowIds = [] }) => { const contextValue = useState({ stepperModal: { diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index 93269fab0a..fafc699ba4 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -14,7 +14,7 @@ export const sanitizeAndParseHTML = (htmlString) => { // Set to false before pushing PR!! otherwise set to true to enable local testing of ContentHighlights components // Test will fail as additional check to ensure this is set to false before pushing PR export const TEST_FLAG = false; -// Test entepriseId for Content Highlights to display card selections and confirmation +// Test enterpriseId for Content Highlights to display card selections and confirmation export const testEnterpriseId = '943b1234-58cf-4376-b8e0-0efcbf4bfdf9'; // function that passes through enterpriseId if TEST_FLAG is false, otherwise returns local testing enterpriseId export const ENABLE_TESTING = (enterpriseId, enableTest = TEST_FLAG) => { @@ -42,7 +42,7 @@ export const TAB_TITLES = { // Max length of highlight title in stepper export const MAX_HIGHLIGHT_TITLE_LENGTH = 60; -// Max highlight sets per enteprise curation +// Max highlight sets per enterprise curation export const MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION = 12; // Max number of content items per highlight set diff --git a/src/components/ContentHighlights/data/tests/constants.test.js b/src/components/ContentHighlights/data/tests/constants.test.js index b12f3f1982..00125b4f57 100644 --- a/src/components/ContentHighlights/data/tests/constants.test.js +++ b/src/components/ContentHighlights/data/tests/constants.test.js @@ -33,7 +33,7 @@ describe('constants', () => { }); it('renders title name in string functions', () => { const highlightTitle = 'test-title'; - // eslint-disable-next-line react/prop-types + const TestComponent = ({ children }) => (

{children} diff --git a/src/components/EnterpriseApp/EnterpriseApp.test.jsx b/src/components/EnterpriseApp/EnterpriseApp.test.jsx index 2aeace95d8..04ea668d69 100644 --- a/src/components/EnterpriseApp/EnterpriseApp.test.jsx +++ b/src/components/EnterpriseApp/EnterpriseApp.test.jsx @@ -36,7 +36,7 @@ const EnterpriseAppContextProvider = ({ children }) => ( jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), __esModule: true, - // eslint-disable-next-line react/prop-types + Route: (props) => {props.path}, Switch: (props) => props.children, Redirect: () => 'Redirect', diff --git a/src/components/EnterpriseList/EnterpriseList.test.jsx b/src/components/EnterpriseList/EnterpriseList.test.jsx index 47b04098c0..d466f08366 100644 --- a/src/components/EnterpriseList/EnterpriseList.test.jsx +++ b/src/components/EnterpriseList/EnterpriseList.test.jsx @@ -35,7 +35,6 @@ const store = mockStore({ }, }); -// eslint-disable-next-line react/prop-types const EnterpriseListWrapper = ({ initialEntries, ...rest }) => ( diff --git a/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx b/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx index 83745cdd7b..a99f268db3 100644 --- a/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx +++ b/src/components/learner-credit-management/BudgetDetailCatalogTabContents.jsx @@ -1,13 +1,49 @@ import React from 'react'; +import { InstantSearch } from 'react-instantsearch-dom'; +import algoliasearch from 'algoliasearch/lite'; import { Row, Col } from '@edx/paragon'; -const BudgetDetailCatalogTabContents = () => ( - - -

Budget Name

-

TODO

- - -); +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 { configuration } from '../../config'; + +const BudgetDetailCatalogTabContents = () => { + const language = { + attribute: LANGUAGE_REFINEMENT, + title: 'Language', + }; + const learningType = { + attribute: LEARNING_TYPE_REFINEMENT, + title: 'Learning Type', + }; + // Add search facet filters if they don't exist in the list yet + [language, learningType].forEach((refinement) => { + if (!SEARCH_FACET_FILTERS.some((filter) => filter.attribute === refinement.attribute)) { + SEARCH_FACET_FILTERS.push(refinement); + } + }); + + const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, + ); + return ( + + + + + + + + + + ); +}; export default BudgetDetailCatalogTabContents; diff --git a/src/components/learner-credit-management/cards/CourseCard.jsx b/src/components/learner-credit-management/cards/CourseCard.jsx new file mode 100644 index 0000000000..9284369239 --- /dev/null +++ b/src/components/learner-credit-management/cards/CourseCard.jsx @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// variables taken from algolia not in camelcase +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, +} from '@edx/paragon'; +import { EXEC_COURSE_TYPE } from '../data/constants'; +import { formatDate } from '../data/utils'; + +const CourseCard = ({ + onClick, original, +}) => { + const { + title, + cardImageUrl, + courseType, + normalizedMetadata, + partners, + } = camelCaseObject(original); + + let priceText; + const altText = `${title} course image`; + + return ( + onClick(original)} + orientation="horizontal" + tabIndex="0" + > + +
+
+

{title}

+

{partners[0]?.name}

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

+ )} +

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

+
+ +

{priceText}

+

Per learner price

+ + + + + +
+
+
+ ); +}; + +CourseCard.defaultProps = { + onClick: () => {}, +}; + +CourseCard.propTypes = { + onClick: PropTypes.func, + original: PropTypes.shape({ + title: PropTypes.string, + cardImageUrl: PropTypes.string, + partners: PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string, + logo_image_url: PropTypes.string, + }), + ), + normalizedMetadata: PropTypes.shape({ + startDate: PropTypes.string, + endDate: PropTypes.string, + enrollByDate: PropTypes.string, + }), + courseType: PropTypes.string, + }).isRequired, +}; + +export default CourseCard; diff --git a/src/components/learner-credit-management/cards/CourseCard.test.jsx b/src/components/learner-credit-management/cards/CourseCard.test.jsx new file mode 100644 index 0000000000..963137e178 --- /dev/null +++ b/src/components/learner-credit-management/cards/CourseCard.test.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +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', + 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: 'self_paced' }, +}; + +const defaultProps = { + original: originalData, + learningType: CONTENT_TYPE_COURSE, +}; + +const execEdData = { + title: 'Exec Ed 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.00' }], +}; + +const execEdProps = { + original: execEdData, + learningType: EXEC_ED_TITLE, +}; + +describe('Course card works as expected', () => { + test('card renders as expected', () => { + render( + + + , + ); + expect(screen.queryByText(defaultProps.original.title)).toBeInTheDocument(); + expect( + screen.queryByText(defaultProps.original.partners[0].name), + ).toBeInTheDocument(); + expect(screen.queryByText('Course Title')).toBeInTheDocument(); + expect(screen.queryByText('Per learner price')).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( + + + , + ); + const imageAltText = `${originalData.title} course image`; + fireEvent.error(screen.getByAltText(imageAltText)); + await expect(screen.getByAltText(imageAltText).src).not.toBeUndefined; + }); +}); diff --git a/src/components/learner-credit-management/data/constants.js b/src/components/learner-credit-management/data/constants.js index defca8d674..37f453bd4a 100644 --- a/src/components/learner-credit-management/data/constants.js +++ b/src/components/learner-credit-management/data/constants.js @@ -24,3 +24,13 @@ export const BUDGET_DETAIL_TAB_LABELS = { [BUDGET_DETAIL_ACTIVITY_TAB]: 'Activity', [BUDGET_DETAIL_CATALOG_TAB]: 'Catalog', }; + +// Facet filters +export const LEARNING_TYPE_REFINEMENT = 'learning_type'; +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'; diff --git a/src/components/learner-credit-management/data/hooks/hooks.js b/src/components/learner-credit-management/data/hooks/hooks.js new file mode 100644 index 0000000000..306ad58fde --- /dev/null +++ b/src/components/learner-credit-management/data/hooks/hooks.js @@ -0,0 +1,13 @@ +import { useMemo, useState } from 'react'; + +import { CONTENT_TYPE_COURSE } from '../constants'; + +// eslint-disable-next-line import/prefer-default-export +export const useSelectedCourse = () => { + const [course, setCourse] = useState(null); + const isCourse = useMemo( + () => course?.contentType === CONTENT_TYPE_COURSE, + [course], + ); + return [course, setCourse, isCourse]; +}; diff --git a/src/components/learner-credit-management/data/utils.js b/src/components/learner-credit-management/data/utils.js index 376479f6a5..4705d62507 100644 --- a/src/components/learner-credit-management/data/utils.js +++ b/src/components/learner-credit-management/data/utils.js @@ -1,4 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; +import dayjs from 'dayjs'; + import { LOW_REMAINING_BALANCE_PERCENT_THRESHOLD, NO_BALANCE_REMAINING_DOLLAR_THRESHOLD, @@ -181,3 +183,7 @@ export const orderOffers = (offers) => { return offers; }; + +export function formatDate(date) { + return dayjs(date).format('MMM D, YYYY'); +} diff --git a/src/components/learner-credit-management/index.js b/src/components/learner-credit-management/index.js new file mode 100644 index 0000000000..ff16dee97e --- /dev/null +++ b/src/components/learner-credit-management/index.js @@ -0,0 +1,4 @@ +import MultipleBudgetsPage from './MultipleBudgetsPage'; +import './learner-credit.scss'; + +export default MultipleBudgetsPage; diff --git a/src/components/learner-credit-management/learner-credit.scss b/src/components/learner-credit-management/learner-credit.scss new file mode 100644 index 0000000000..c26c3e9859 --- /dev/null +++ b/src/components/learner-credit-management/learner-credit.scss @@ -0,0 +1,25 @@ +.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 new file mode 100644 index 0000000000..43cc7aae8d --- /dev/null +++ b/src/components/learner-credit-management/search/CatalogSearch.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import algoliasearch from 'algoliasearch/lite'; +import { Configure, InstantSearch } from 'react-instantsearch-dom'; + +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { SearchHeader } from '@edx/frontend-enterprise-catalog-search'; + +import { configuration } from '../../../config'; +import CatalogSearchResults from './CatalogSearchResults'; + +const CatalogSearch = () => { + const { budgetId } = useParams(); + const searchClient = algoliasearch(configuration.ALGOLIA.APP_ID, configuration.ALGOLIA.SEARCH_API_KEY); + + const searchFilters = `enterprise_catalog_query_uuids:${budgetId}`; + + return ( +
+ + +
+ + +
+ +
+
+ ); +}; + +export default CatalogSearch; diff --git a/src/components/learner-credit-management/search/CatalogSearchResults.jsx b/src/components/learner-credit-management/search/CatalogSearchResults.jsx new file mode 100644 index 0000000000..60c20e0262 --- /dev/null +++ b/src/components/learner-credit-management/search/CatalogSearchResults.jsx @@ -0,0 +1,149 @@ +import React, { useEffect, useMemo } from 'react'; +import { connectStateResults } from 'react-instantsearch-dom'; +import PropTypes from 'prop-types'; + +import { SearchPagination } from '@edx/frontend-enterprise-catalog-search'; +import { FormattedMessage, injectIntl } from '@edx/frontend-platform/i18n'; +import { + Alert, CardView, DataTable, Skeleton, +} from '@edx/paragon'; + +import CourseCard from '../cards/CourseCard'; + +export const ERROR_MESSAGE = 'An error occurred while retrieving data'; + +export const SKELETON_DATA_TESTID = 'enterprise-catalog-skeleton'; + +/** + * The core search results rendering component. + * + * Wrapping this in `connectStateResults()` will inject the first few props. + * + * @param {object} args arguments + * @param {object} args.searchResults Results of search (see: `connectStateResults``) + * @param {Boolean} args.isSearchStalled Whether search is stalled (see: `connectStateResults`) + * @param {object} args.error Error with `message` field if available (see: `connectStateResults``) + */ + +export const BaseCatalogSearchResults = ({ + searchResults, + // algolia recommends this prop instead of searching + isSearchStalled, + error, + setNoContent, +}) => { + const courseColumns = useMemo( + () => [ + { + Header: 'Course name', + accessor: 'title', + }, + { + Header: 'Partner', + accessor: 'partners[0].name', + }, + { + Header: 'A la carte course price', + accessor: 'first_enrollable_paid_seat_price', + }, + { + Header: 'Associated catalogs', + accessor: 'enterprise_catalog_query_titles', + }, + ], + [], + ); + + const tableData = useMemo( + () => searchResults?.hits || [], + [searchResults?.hits], + ); + + const renderCardComponent = (props) => ; + + useEffect(() => { + setNoContent(searchResults === null || searchResults?.nbHits === 0); + }, [searchResults, setNoContent]); + + if (isSearchStalled) { + return ( +
+ +
+ ); + } + if (error) { + return ( + + + + ); + } + + return ( +
+ + + renderCardComponent(props)} + /> + + + +
+ ); +}; + +BaseCatalogSearchResults.defaultProps = { + searchResults: { disjunctiveFacetsRefinements: [], nbHits: 0, hits: [] }, + error: null, + paginationComponent: SearchPagination, + row: null, + preview: false, + setNoContent: () => {}, +}; + +BaseCatalogSearchResults.propTypes = { + // from Algolia + searchResults: PropTypes.shape({ + _state: PropTypes.shape({ + disjunctiveFacetsRefinements: PropTypes.shape({}), + }), + disjunctiveFacetsRefinements: PropTypes.arrayOf(PropTypes.shape({})), + nbHits: PropTypes.number, + hits: PropTypes.arrayOf(PropTypes.shape({})), + nbPages: PropTypes.number, + hitsPerPage: PropTypes.number, + page: PropTypes.number, + }), + isSearchStalled: PropTypes.bool.isRequired, + error: PropTypes.shape({ + message: PropTypes.string, + }), + + searchState: PropTypes.shape({ + page: PropTypes.number, + }).isRequired, + paginationComponent: PropTypes.func, + // eslint-disable-next-line react/no-unused-prop-types + row: PropTypes.string, + contentType: PropTypes.string.isRequired, + preview: PropTypes.bool, + setNoContent: PropTypes.func, +}; + +export default connectStateResults(injectIntl(BaseCatalogSearchResults)); diff --git a/src/components/learner-credit-management/tests/CatalogSearch.test.jsx b/src/components/learner-credit-management/tests/CatalogSearch.test.jsx new file mode 100644 index 0000000000..ee74751e87 --- /dev/null +++ b/src/components/learner-credit-management/tests/CatalogSearch.test.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import { + SEARCH_FACET_FILTERS, + SearchContext, +} from '@edx/frontend-enterprise-catalog-search'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { screen } from '@testing-library/react'; +import { renderWithRouter } from '../../test/testUtils'; +import CatalogSearch from '../search/CatalogSearch'; + +jest.mock('react-instantsearch-dom', () => ({ + ...jest.requireActual('react-instantsearch-dom'), + InstantSearch: () =>
SEARCH
, + Index: () =>
SEARCH
, +})); + +const DEFAULT_SEARCH_CONTEXT_VALUE = { refinements: {} }; + +const SearchDataWrapper = ({ children, searchContextValue }) => ( + + + {children} + + +); + +describe('Catalog Search component', () => { + it('properly renders component', () => { + renderWithRouter( + + + , + ); + expect(screen.getByText('SEARCH')).toBeInTheDocument(); + }); +}); diff --git a/src/components/learner-credit-management/tests/CatalogSearchResults.test.jsx b/src/components/learner-credit-management/tests/CatalogSearchResults.test.jsx new file mode 100644 index 0000000000..34a75e3ac0 --- /dev/null +++ b/src/components/learner-credit-management/tests/CatalogSearchResults.test.jsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +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 { CONTENT_TYPE_COURSE } from '../data/constants'; + +// Mocking this connected component so as not to have to mock the algolia Api +const PAGINATE_ME = 'PAGINATE ME :)'; +const PaginationComponent = () =>
{PAGINATE_ME}
; + +// all we are testing is routes, we don't need InstantSearch to work here +jest.mock('react-instantsearch-dom', () => ({ + ...jest.requireActual('react-instantsearch-dom'), + InstantSearch: () =>
Popular Courses
, + Index: () =>
Popular Courses
, +})); + +const DEFAULT_SEARCH_CONTEXT_VALUE = { refinements: {} }; + +const SearchDataWrapper = ({ + + children, + + searchContextValue = DEFAULT_SEARCH_CONTEXT_VALUE, +}) => ( + + {children} + +); + +const mockConfig = () => ({ + EDX_FOR_BUSINESS_TITLE: 'ayylmao', + EDX_ENTERPRISE_ALACARTE_TITLE: 'baz', + FEATURE_CARD_VIEW_ENABLED: 'True', +}); + +jest.mock('@edx/frontend-platform', () => ({ + ...jest.requireActual('@edx/frontend-platform'), + getConfig: () => mockConfig(), +})); + +const TEST_COURSE_NAME = 'test course'; +const TEST_PARTNER = 'edx'; +const TEST_CATALOGS = ['baz']; + +const TEST_COURSE_NAME_2 = 'test course 2'; +const TEST_PARTNER_2 = 'edx 2'; +const TEST_CATALOGS_2 = ['baz', 'ayylmao']; + +const searchResults = { + nbHits: 2, + hitsPerPage: 10, + pageIndex: 10, + pageCount: 5, + nbPages: 6, + hits: [ + { + title: TEST_COURSE_NAME, + partners: [{ name: TEST_PARTNER, logo_image_url: '' }], + enterprise_catalog_query_titles: TEST_CATALOGS, + card_image_url: 'http://url.test.location', + first_enrollable_paid_seat_price: 100, + 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', + }, + }, + { + title: TEST_COURSE_NAME_2, + partners: [{ name: TEST_PARTNER_2, logo_image_url: '' }], + enterprise_catalog_query_titles: TEST_CATALOGS_2, + card_image_url: 'http://url.test2.location', + first_enrollable_paid_seat_price: 99, + 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, + _state: { disjunctiveFacetsRefinements: { foo: 'bar' } }, +}; + +const defaultProps = { + paginationComponent: PaginationComponent, + searchResults, + isSearchStalled: false, + searchState: { page: 1 }, + error: null, + contentType: CONTENT_TYPE_COURSE, + // 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(() => { + jest.resetModules(); // Most important - it clears the cache + process.env = { ...OLD_ENV }; // Make a copy + }); + afterEach(() => { + process.env = OLD_ENV; // Restore old environment + }); + + test('all courses rendered when search results available', async () => { + render( + + + + + , + , + ); + expect(screen.queryByText(TEST_COURSE_NAME)).toBeInTheDocument(); + 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(); + }); +}); diff --git a/src/components/settings/tests/SettingsTabs.test.jsx b/src/components/settings/tests/SettingsTabs.test.jsx index 7dff9d0a67..ed8576396d 100644 --- a/src/components/settings/tests/SettingsTabs.test.jsx +++ b/src/components/settings/tests/SettingsTabs.test.jsx @@ -76,7 +76,6 @@ const mockStore = configureMockStore([thunk]); const getMockStore = store => mockStore(store); const defaultStore = getMockStore({ ...initialStore }); -// eslint-disable-next-line react/prop-types const SettingsTabsWithRouter = ({ store = defaultStore }) => ( diff --git a/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/EnrollBulkAction.test.jsx b/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/EnrollBulkAction.test.jsx index c603c2ebd8..3d42aaa9a2 100644 --- a/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/EnrollBulkAction.test.jsx +++ b/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/EnrollBulkAction.test.jsx @@ -50,7 +50,6 @@ const initialStore = mockStore({ }, }); -// eslint-disable-next-line react/prop-types const EnrollBulkActionWithProvider = ({ store = initialStore, ...rest }) => ( diff --git a/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RemindBulkAction.test.jsx b/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RemindBulkAction.test.jsx index e01e23160d..329cff540b 100644 --- a/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RemindBulkAction.test.jsx +++ b/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RemindBulkAction.test.jsx @@ -42,7 +42,6 @@ const initialStore = mockStore({ }, }); -// eslint-disable-next-line react/prop-types const RemindBulkActionWithProvider = ({ store = initialStore, ...rest }) => ( diff --git a/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RevokeBulkAction.test.jsx b/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RevokeBulkAction.test.jsx index 2e2bbcf624..e5f947b638 100644 --- a/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RevokeBulkAction.test.jsx +++ b/src/components/subscriptions/licenses/LicenseManagementTable/bulk-actions/RevokeBulkAction.test.jsx @@ -39,7 +39,6 @@ const initialStore = mockStore({ }, }); -// eslint-disable-next-line react/prop-types const RevokeBulkActionWithProvider = ({ store = initialStore, ...rest }) => ( diff --git a/src/components/subscriptions/licenses/LicenseManagementTable/tests/index.test.jsx b/src/components/subscriptions/licenses/LicenseManagementTable/tests/index.test.jsx index 6ac5cc5086..de07041417 100644 --- a/src/components/subscriptions/licenses/LicenseManagementTable/tests/index.test.jsx +++ b/src/components/subscriptions/licenses/LicenseManagementTable/tests/index.test.jsx @@ -58,7 +58,6 @@ const expiredSubscriptionPlan = ( }; }; -// eslint-disable-next-line react/prop-types const LicenseManagementTableWrapper = ({ subscriptionPlan, ...props }) => ( diff --git a/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx b/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx index e476bdce5a..7e3633180d 100644 --- a/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx +++ b/src/components/subscriptions/tests/MultipleSubscriptionsPage.test.jsx @@ -63,7 +63,6 @@ const defaultSubscriptions = { const mockStore = configureMockStore([thunk]); -// eslint-disable-next-line react/prop-types const MultipleSubscriptionsPageWrapper = ({ subscriptions = defaultSubscriptions, ...props }) => ( diff --git a/src/components/subscriptions/tests/expiration/SubscriptionExpirationBanner.test.jsx b/src/components/subscriptions/tests/expiration/SubscriptionExpirationBanner.test.jsx index a0dc6aea45..e743f3ae70 100644 --- a/src/components/subscriptions/tests/expiration/SubscriptionExpirationBanner.test.jsx +++ b/src/components/subscriptions/tests/expiration/SubscriptionExpirationBanner.test.jsx @@ -27,7 +27,7 @@ jest.mock('@edx/frontend-enterprise-utils', () => { }); // PropType validation for state is done by SubscriptionManagementContext -// eslint-disable-next-line react/prop-types + const ExpirationBannerWrapper = ({ detailState, isSubscriptionPlanDetails = false }) => ( diff --git a/src/components/subscriptions/tests/expiration/SubscriptionExpirationModals.test.jsx b/src/components/subscriptions/tests/expiration/SubscriptionExpirationModals.test.jsx index 2dbbfe237a..a86f455417 100644 --- a/src/components/subscriptions/tests/expiration/SubscriptionExpirationModals.test.jsx +++ b/src/components/subscriptions/tests/expiration/SubscriptionExpirationModals.test.jsx @@ -29,7 +29,7 @@ jest.mock('@edx/frontend-enterprise-utils', () => { }); // PropType validation for state is done by SubscriptionManagementContext -// eslint-disable-next-line react/prop-types + const ExpirationModalsWithContext = ({ detailState }) => ( diff --git a/src/components/test/testUtils.jsx b/src/components/test/testUtils.jsx index b5ac1bf34e..02a98208fb 100644 --- a/src/components/test/testUtils.jsx +++ b/src/components/test/testUtils.jsx @@ -12,7 +12,6 @@ export function renderWithRouter( history = createMemoryHistory({ initialEntries: [route] }), } = {}, ) { - // eslint-disable-next-line react/prop-types const Wrapper = ({ children }) => ( {children} ); diff --git a/src/containers/EnterpriseApp/EnterpriseApp.test.jsx b/src/containers/EnterpriseApp/EnterpriseApp.test.jsx index 6281ab463d..f10e8b0dd8 100644 --- a/src/containers/EnterpriseApp/EnterpriseApp.test.jsx +++ b/src/containers/EnterpriseApp/EnterpriseApp.test.jsx @@ -47,13 +47,13 @@ const EnterpriseAppContextProvider = ({ jest.mock('../../components/EnterpriseApp/EnterpriseAppContextProvider', () => ({ __esModule: true, ...jest.requireActual('../../components/EnterpriseApp/EnterpriseAppContextProvider'), - // eslint-disable-next-line react/prop-types + default: ({ children }) => {children}, })); jest.mock('../Sidebar', () => ({ __esModule: true, - // eslint-disable-next-line react/prop-types + default: ({ children }) =>
{children}
, })); @@ -94,7 +94,6 @@ const initialState = { dashboardInsights: {}, }; -// eslint-disable-next-line react/prop-types const EnterpriseAppWrapper = ({ store, initialEntries, ...props }) => ( diff --git a/src/data/hooks.js b/src/data/hooks.js index 5f9e7df7b1..c44b512dd5 100644 --- a/src/data/hooks.js +++ b/src/data/hooks.js @@ -1,4 +1,8 @@ -import { useEffect, useRef } from 'react'; +import { + useEffect, useMemo, useState, useRef, +} from 'react'; + +import { CONTENT_TYPE_COURSE } from '../components/learner-credit-management/data/constants'; export function useInterval(callback, delay) { const savedCallback = useRef(); @@ -41,3 +45,12 @@ export function useTimeout(callback, delay) { timeoutIdRef.current = null; }, [callback, delay]); } + +export const useSelectedCourse = () => { + const [course, setCourse] = useState(null); + const isCourse = useMemo( + () => course?.contentType === CONTENT_TYPE_COURSE, + [course], + ); + return [course, setCourse, isCourse]; +};