diff --git a/package-lock.json b/package-lock.json index fa1b9efc27..98aa00053d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,6 +76,7 @@ "enzyme-adapter-react-16": "1.15.6", "husky": "0.14.3", "identity-obj-proxy": "3.0.0", + "jest-canvas-mock": "^2.4.0", "jest-localstorage-mock": "^2.4.22", "postcss": "8.1.0", "react-dev-utils": "11.0.4", @@ -9713,6 +9714,12 @@ "node": ">=4" } }, + "node_modules/cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true + }, "node_modules/csso": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", @@ -15425,6 +15432,16 @@ "node": ">= 10.14.2" } }, + "node_modules/jest-canvas-mock": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz", + "integrity": "sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==", + "dev": true, + "dependencies": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "node_modules/jest-changed-files": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", @@ -18672,6 +18689,21 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, + "node_modules/moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "dependencies": { + "color-name": "^1.1.4" + } + }, + "node_modules/moo-color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, "node_modules/mozjpeg": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/mozjpeg/-/mozjpeg-7.1.1.tgz", @@ -33461,6 +33493,12 @@ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true }, + "cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true + }, "csso": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/csso/-/csso-4.2.0.tgz", @@ -37823,6 +37861,16 @@ "jest-cli": "^26.6.3" } }, + "jest-canvas-mock": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jest-canvas-mock/-/jest-canvas-mock-2.4.0.tgz", + "integrity": "sha512-mmMpZzpmLzn5vepIaHk5HoH3Ka4WykbSoLuG/EKoJd0x0ID/t+INo1l8ByfcUJuDM+RIsL4QDg/gDnBbrj2/IQ==", + "dev": true, + "requires": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.2" + } + }, "jest-changed-files": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", @@ -40326,6 +40374,23 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, + "moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "requires": { + "color-name": "^1.1.4" + }, + "dependencies": { + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, "mozjpeg": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/mozjpeg/-/mozjpeg-7.1.1.tgz", diff --git a/package.json b/package.json index bdedd8c414..de91a46414 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "enzyme-adapter-react-16": "1.15.6", "husky": "0.14.3", "identity-obj-proxy": "3.0.0", + "jest-canvas-mock": "^2.4.0", "jest-localstorage-mock": "^2.4.22", "postcss": "8.1.0", "react-dev-utils": "11.0.4", diff --git a/src/components/ContentHighlights/ContentHighlightCardContainer.jsx b/src/components/ContentHighlights/ContentHighlightCardContainer.jsx index f339b31449..08b87b2462 100644 --- a/src/components/ContentHighlights/ContentHighlightCardContainer.jsx +++ b/src/components/ContentHighlights/ContentHighlightCardContainer.jsx @@ -8,7 +8,6 @@ import HighlightSetSection from './HighlightSetSection'; const ContentHighlightCardContainer = () => { const { enterpriseCuration: { enterpriseCuration } } = useContext(EnterpriseAppContext); const highlightSets = useHighlightSetsForCuration(enterpriseCuration); - return ( { - const cardLogoSrc = partners?.length === 1 ? partners[0].logoImageUrl : undefined; - const cardLogoAlt = partners?.length === 1 ? `${partners[0].name}'s logo` : undefined; - const cardSubtitle = partners?.map(p => p.name).join(', '); - + const cardInfo = { + cardImgSrc: cardImageUrl, + cardLogoSrc: partners.length === 1 ? partners[0].logoImageUrl : undefined, + cardLogoAlt: partners.length === 1 ? `${partners[0].name}'s logo` : undefined, + cardTitle: {title}, + cardSubtitle: partners.map(p => p.name).join(', '), + cardFooter: getContentHighlightCardFooter({ price, contentType }), + }; + if (href) { + cardInfo.cardTitle = ( + + {title} + + ); + } return ( - + {title}} - subtitle={{cardSubtitle}} + title={cardInfo.cardTitle} + subtitle={{cardInfo.cardSubtitle}} /> {contentType && ( <> )} @@ -39,18 +53,24 @@ const ContentHighlightCardItem = ({ }; ContentHighlightCardItem.propTypes = { + isLoading: PropTypes.bool, cardImageUrl: PropTypes.string, title: PropTypes.string.isRequired, + href: PropTypes.string, contentType: PropTypes.oneOf(['course', 'program', 'learnerpathway']).isRequired, partners: PropTypes.arrayOf(PropTypes.shape({ name: PropTypes.string, uuid: PropTypes.string, logoImageUrl: PropTypes.string, })).isRequired, + price: PropTypes.number, }; ContentHighlightCardItem.defaultProps = { + isLoading: false, + href: undefined, cardImageUrl: undefined, + price: undefined, }; export default ContentHighlightCardItem; diff --git a/src/components/ContentHighlights/ContentHighlightSet.jsx b/src/components/ContentHighlights/ContentHighlightSet.jsx index d8bf931179..c3ddd98995 100644 --- a/src/components/ContentHighlights/ContentHighlightSet.jsx +++ b/src/components/ContentHighlights/ContentHighlightSet.jsx @@ -1,12 +1,19 @@ import { Container } from '@edx/paragon'; +import { useParams } from 'react-router-dom'; +import React from 'react'; import ContentHighlightsCardItemContainer from './ContentHighlightsCardItemsContainer'; import CurrentContentHighlightItemsHeader from './CurrentContentHighlightItemsHeader'; +import { useHighlightSet } from './data/hooks'; -const ContentHighlightSet = () => ( - - - - -); +const ContentHighlightSet = () => { + const { highlightSetUUID } = useParams(); + const { highlightSet, isLoading } = useHighlightSet(highlightSetUUID); + return ( + + + + + ); +}; export default ContentHighlightSet; diff --git a/src/components/ContentHighlights/ContentHighlightSetCardContainer.jsx b/src/components/ContentHighlights/ContentHighlightSetCardContainer.jsx deleted file mode 100644 index dd066951b9..0000000000 --- a/src/components/ContentHighlights/ContentHighlightSetCardContainer.jsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { CardGrid } from '@edx/paragon'; -import { camelCaseObject } from '@edx/frontend-platform'; -import ContentHighlightSetCard from './ContentHighlightSetCard'; -import { TEST_COURSE_HIGHLIGHTS_DATA } from './data/constants'; - -const ContentHighlightSetCardContainer = () => ( - - {camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA).map(({ title, uuid, isPublished }) => ( - - ))} - {/* eslint-enable camelcase */} - -); - -export default ContentHighlightSetCardContainer; diff --git a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx index 2ae62ea6dd..17782ccd60 100644 --- a/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx +++ b/src/components/ContentHighlights/ContentHighlightsCardItemsContainer.jsx @@ -1,34 +1,43 @@ -import React, { useState } from 'react'; -import { CardGrid } from '@edx/paragon'; -import { camelCaseObject } from '@edx/frontend-platform'; +import React from 'react'; +import { CardGrid, Alert } from '@edx/paragon'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import ContentHighlightCardItem from './ContentHighlightCardItem'; -import { TEST_COURSE_HIGHLIGHTS_DATA } from './data/constants'; +import { + DEFAULT_ERROR_MESSAGE, HIGHLIGHTS_CARD_GRID_COLUMN_SIZES, MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET, +} from './data/constants'; +import SkeletonContentCardContainer from './SkeletonContentCardContainer'; +import { generateAboutPageUrl } from './data/utils'; -const ContentHighlightsCardItemsContainer = () => { - const [highlightCourses] = useState( - camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA)[0]?.highlightedContent, - ); - - if (!highlightCourses) { - return null; +const ContentHighlightsCardItemsContainer = ({ enterpriseSlug, isLoading, highlightedContent }) => { + if (isLoading) { + return ( + + ); + } + if (!highlightedContent || highlightedContent?.length === 0) { + return ( + + {DEFAULT_ERROR_MESSAGE.EMPTY_HIGHLIGHT_SET} + + ); } - return ( - - {highlightCourses.map(({ - uuid, title, contentType, authoringOrganizations, + + {highlightedContent.map(({ + uuid, title, contentType, authoringOrganizations, contentKey, }) => ( ))} @@ -36,4 +45,24 @@ const ContentHighlightsCardItemsContainer = () => { ); }; -export default ContentHighlightsCardItemsContainer; +ContentHighlightsCardItemsContainer.propTypes = { + enterpriseSlug: PropTypes.string.isRequired, + isLoading: PropTypes.bool.isRequired, + highlightedContent: PropTypes.arrayOf(PropTypes.shape({ + uuid: PropTypes.string, + contentType: PropTypes.oneOf(['course', 'program', 'learnerpathway']), + title: PropTypes.string, + cardImageUrl: PropTypes.string, + authoringOrganizations: PropTypes.arrayOf(PropTypes.shape({ + name: PropTypes.string, + logoImageUrl: PropTypes.string, + uuid: PropTypes.string, + })), + })).isRequired, +}; + +const mapStateToProps = state => ({ + enterpriseSlug: state.portalConfiguration.enterpriseSlug, +}); + +export default connect(mapStateToProps)(ContentHighlightsCardItemsContainer); diff --git a/src/components/ContentHighlights/ContentHighlightsContext.jsx b/src/components/ContentHighlights/ContentHighlightsContext.jsx index fa01c5bcb5..b838f3f47c 100644 --- a/src/components/ContentHighlights/ContentHighlightsContext.jsx +++ b/src/components/ContentHighlights/ContentHighlightsContext.jsx @@ -23,7 +23,6 @@ const ContentHighlightsContextProvider = ({ children }) => { contentHighlights: [], searchClient, }); - return ( {children} diff --git a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx index 9b136c4ecd..7baf4630e7 100644 --- a/src/components/ContentHighlights/ContentHighlightsDashboard.jsx +++ b/src/components/ContentHighlights/ContentHighlightsDashboard.jsx @@ -30,7 +30,7 @@ const ContentHighlightsDashboardBase = ({ children }) => { }, [history, location, locationState]); return ( - + {children} { - const { highlightSetUUID } = useParams(); - - const highlightTitle = highlightSetUUID; - - const titleName = `${highlightTitle} - Highlights`; - +const CurrentContentHighlightItemsHeader = ({ isLoading, highlightTitle }) => { + if (isLoading) { + return ( + +

+ + +
+ ); + } return ( <> - +

{highlightTitle} @@ -25,4 +28,9 @@ const CurrentContentHighlightItemsHeader = () => { ); }; +CurrentContentHighlightItemsHeader.propTypes = { + isLoading: PropTypes.bool.isRequired, + highlightTitle: PropTypes.string.isRequired, +}; + export default CurrentContentHighlightItemsHeader; diff --git a/src/components/ContentHighlights/HighlightSetSection.jsx b/src/components/ContentHighlights/HighlightSetSection.jsx index a0c58776f4..fbc11fb1cc 100644 --- a/src/components/ContentHighlights/HighlightSetSection.jsx +++ b/src/components/ContentHighlights/HighlightSetSection.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import { CardGrid } from '@edx/paragon'; import ContentHighlightSetCard from './ContentHighlightSetCard'; +import { HIGHLIGHTS_CARD_GRID_COLUMN_SIZES } from './data/constants'; const HighlightSetSection = ({ title: sectionTitle, @@ -15,13 +16,7 @@ const HighlightSetSection = ({ return (

{sectionTitle}

- + {highlightSets.map(({ title, uuid, diff --git a/src/components/ContentHighlights/HighlightStepper/ContentConfirmContentCard.jsx b/src/components/ContentHighlights/HighlightStepper/ContentConfirmContentCard.jsx new file mode 100644 index 0000000000..155869f55a --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/ContentConfirmContentCard.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Delete } from '@edx/paragon/icons'; +import { IconButton, Icon } from '@edx/paragon'; +import { connect } from 'react-redux'; +import ContentHighlightCardItem from '../ContentHighlightCardItem'; +import { useContentHighlightsContext } from '../data/hooks'; +import { generateAboutPageUrl } from '../data/utils'; + +const ContentConfirmContentCard = ({ enterpriseSlug, original }) => { + const { deleteSelectedRowId } = useContentHighlightsContext(); + const { + title, + contentType, + partners, + cardImageUrl, + originalImageUrl, + firstEnrollablePaidSeatPrice, + aggregationKey, + } = original; + + return ( +
+ + deleteSelectedRowId(aggregationKey)} + className="ml-1 flex-shrink-0" + /> +
+ ); +}; + +ContentConfirmContentCard.propTypes = { + enterpriseSlug: PropTypes.string.isRequired, + original: PropTypes.shape({ + title: PropTypes.string, + contentType: PropTypes.string, + partners: PropTypes.arrayOf(PropTypes.shape()), + cardImageUrl: PropTypes.string, + originalImageUrl: PropTypes.string, + firstEnrollablePaidSeatPrice: PropTypes.number, + aggregationKey: PropTypes.string, + }).isRequired, +}; + +export const mapStateToProps = state => ({ + enterpriseSlug: state.portalConfiguration.enterpriseSlug, +}); + +export default connect(mapStateToProps)(ContentConfirmContentCard); diff --git a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx index 79fc4a5c63..c34011f17f 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentHighlightStepper.jsx @@ -43,7 +43,6 @@ const ContentHighlightStepper = ({ enterpriseId }) => { } = useContext(EnterpriseAppContext); const [currentStep, setCurrentStep] = useState(steps[0]); const [isPublishing, setIsPublishing] = useState(false); - const { resetStepperModal } = useContentHighlightsContext(); const isStepperModalOpen = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.isOpen); const titleStepValidationError = useContextSelector( @@ -58,6 +57,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { ContentHighlightsContext, v => v[0].stepperModal.currentSelectedRowIds, ); + const closeStepperModal = useCallback(() => { resetStepperModal(); setCurrentStep(steps[0]); @@ -69,7 +69,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { const newHighlightSet = { title: highlightTitle, isPublished: true, - // TODO: pass along the selected content keys! + content_keys: Object.keys(currentSelectedRowIds).map(key => key.split(':')[1]), }; const response = await EnterpriseCatalogApiService.createHighlightSet(enterpriseId, newHighlightSet); const result = camelCaseObject(response.data); @@ -78,7 +78,7 @@ const ContentHighlightStepper = ({ enterpriseId }) => { isPublished: result.isPublished, title: result.title, uuid: result.uuid, - highlightedContentUuids: [], + highlightedContentUuids: result.highlightedContentUuids || [], }; dispatchEnterpriseCuration(enterpriseCurationActions.addHighlightSet(transformedHighlightSet)); closeStepperModal(); diff --git a/src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx b/src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx index cab87be505..968176da30 100644 --- a/src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx +++ b/src/components/ContentHighlights/HighlightStepper/ContentSearchResultCard.jsx @@ -1,35 +1,50 @@ import React from 'react'; import PropTypes from 'prop-types'; - +import { connect } from 'react-redux'; import ContentHighlightCardItem from '../ContentHighlightCardItem'; +import { generateAboutPageUrl } from '../data/utils'; -const ContentSearchResultCard = ({ original }) => { +const ContentSearchResultCard = ({ enterpriseSlug, original }) => { const { + aggregationKey, title, contentType, partners, cardImageUrl, originalImageUrl, + firstEnrollablePaidSeatPrice, } = original; - return ( ); }; ContentSearchResultCard.propTypes = { + enterpriseSlug: PropTypes.string.isRequired, original: PropTypes.shape({ + aggregationKey: PropTypes.string, title: PropTypes.string, contentType: PropTypes.string, partners: PropTypes.arrayOf(PropTypes.shape()), cardImageUrl: PropTypes.string, originalImageUrl: PropTypes.string, + firstEnrollablePaidSeatPrice: PropTypes.number, }).isRequired, }; -export default ContentSearchResultCard; +const mapStateToProps = state => ({ + enterpriseSlug: state.portalConfiguration.enterpriseSlug, +}); + +export default connect(mapStateToProps)(ContentSearchResultCard); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx index 5309294ad4..45f1f8d76f 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperConfirmContent.jsx @@ -1,4 +1,6 @@ -import React, { useMemo } from 'react'; +import React, { + useMemo, +} from 'react'; import PropTypes from 'prop-types'; import { useContextSelector } from 'use-context-selector'; import { @@ -7,54 +9,43 @@ import { Col, Icon, CardGrid, + Alert, } from '@edx/paragon'; import { Assignment } from '@edx/paragon/icons'; import { camelCaseObject } from '@edx/frontend-platform'; import { Configure, InstantSearch, connectStateResults } from 'react-instantsearch-dom'; - +import { connect } from 'react-redux'; import { configuration } from '../../../config'; -import { STEPPER_STEP_TEXT, MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET } from '../data/constants'; +import { + STEPPER_STEP_TEXT, + MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET, + HIGHLIGHTS_CARD_GRID_COLUMN_SIZES, + DEFAULT_ERROR_MESSAGE, +} from '../data/constants'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; -import SkeletonContentCard from '../SkeletonContentCard'; - -const prodEnterpriseId = 'e783bb19-277f-479e-9c41-8b0ed31b4060'; +import ContentConfirmContentCard from './ContentConfirmContentCard'; +import SkeletonContentCardContainer from '../SkeletonContentCardContainer'; -const BaseReviewContentSelections = ({ +export const BaseReviewContentSelections = ({ searchResults, isSearchStalled, }) => { if (isSearchStalled) { return ( - - {[...new Array(8)].map(() => )} - + ); } - if (!searchResults) { - return null; + return (
); } const { hits } = camelCaseObject(searchResults); return ( -
    - {hits.map((highlightedContent) => { - const { aggregationKey, title } = highlightedContent; - return ( -
  • - {title} -
  • - ); - })} -
+ + {hits.map((original) => ( + ))} + ); }; @@ -74,7 +65,7 @@ BaseReviewContentSelections.defaultProps = { const ReviewContentSelections = connectStateResults(BaseReviewContentSelections); -const SelectedContent = () => { +export const SelectedContent = ({ enterpriseId }) => { const searchClient = useContextSelector( ContentHighlightsContext, v => v[0].searchClient, @@ -83,6 +74,7 @@ const SelectedContent = () => { ContentHighlightsContext, v => v[0].stepperModal.currentSelectedRowIds, ); + const currentSelectedRowIds = Object.keys(currentSelectedRowIdsRaw); /* eslint-disable max-len */ @@ -92,7 +84,8 @@ const SelectedContent = () => { */ /* eslint-enable max-len */ const algoliaFilters = useMemo(() => { - let filterString = `enterprise_customer_uuids:${prodEnterpriseId}`; + // import testEnterpriseId from the existing ../data/constants folder and replace with enterpriseId to test locally + let filterString = `enterprise_customer_uuids:${enterpriseId}`; if (currentSelectedRowIds.length > 0) { filterString += ' AND ('; currentSelectedRowIds.forEach((selectedRowId, index) => { @@ -104,10 +97,14 @@ const SelectedContent = () => { filterString += ')'; } return filterString; - }, [currentSelectedRowIds]); + }, [currentSelectedRowIds, enterpriseId]); if (currentSelectedRowIds.length === 0) { - return null; + return ( + + {DEFAULT_ERROR_MESSAGE.EMPTY_SELECTEDROWIDS} + + ); } return ( @@ -124,7 +121,11 @@ const SelectedContent = () => { ); }; -const HighlightStepperConfirmContent = () => ( +SelectedContent.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + +const HighlightStepperConfirmContent = ({ enterpriseId }) => ( @@ -134,8 +135,16 @@ const HighlightStepperConfirmContent = () => (

- +
); -export default HighlightStepperConfirmContent; +HighlightStepperConfirmContent.propTypes = { + enterpriseId: PropTypes.string.isRequired, +}; + +const mapStateToProps = (state) => ({ + enterpriseId: state.portalConfiguration.enterpriseId, +}); + +export default connect(mapStateToProps)(HighlightStepperConfirmContent); diff --git a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx index 75a68c2ef1..e38169a46d 100644 --- a/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx +++ b/src/components/ContentHighlights/HighlightStepper/HighlightStepperSelectContentSearch.jsx @@ -38,7 +38,8 @@ const HighlightStepperSelectContent = ({ enterpriseId }) => { ContentHighlightsContext, v => v[0].searchClient, ); - + // TODO: replace testEnterpriseId with enterpriseID before push, + // uncomment out import and replace with testEnterpriseId to test const searchFilters = `enterprise_customer_uuids:${enterpriseId} AND advertised_course_run.upgrade_deadline > ${currentEpoch}`; return ( @@ -90,12 +91,9 @@ const BaseHighlightStepperSelectContentDataTable = ({ searchResults, }) => { const [currentView, setCurrentView] = useState(defaultActiveStateValue); - const tableData = useMemo(() => camelCaseObject(searchResults?.hits || []), [searchResults]); - const searchResultsItemCount = searchResults?.nbHits || 0; const searchResultsPageCount = searchResults?.nbPages || 0; - return ( { + if (!element.objectID) { + testCourseData[index].objectID = index + 1; + } +}); +const mockDeleteSelectedRowId = jest.fn(); +jest.mock('../../data/hooks'); +useContentHighlightsContext.mockReturnValue({ + deleteSelectedRowId: mockDeleteSelectedRowId, +}); + +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); + +const ContentHighlightContentCardWrapper = ({ + // eslint-disable-next-line react/prop-types + store = mockStore(initialState), +}) => { + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: testCourseAggregation, + }, + contentHighlights: [], + searchClient, + }); + return ( + + + {testCourseData.map((original) => ( + + ))} + + + ); +}; + +describe('', () => { + it('renders the correct content', () => { + renderWithRouter(); + for (let i = 0; i < testCourseData.length; i++) { + expect(screen.getByText(testCourseData[i].title)).toBeInTheDocument(); + expect(screen.getByText(testCourseData[i].firstEnrollablePaidSeatPrice, { exact: false })).toBeInTheDocument(); + // eslint-disable-next-line max-len + expect(screen.queryAllByText(FOOTER_TEXT_BY_CONTENT_TYPE[testCourseData[i].contentType], { exact: false })).toBeTruthy(); + expect(screen.queryAllByText(testCourseData[i].partners[0].name)).toBeTruthy(); + } + }); + it('deletes the correct content', () => { + renderWithRouter(); + const deleteButton = screen.getAllByRole('button', { 'aria-label': 'Delete' }); + userEvent.click(deleteButton[0]); + expect(mockDeleteSelectedRowId).toHaveBeenCalledWith(testCourseData[0].aggregationKey); + }); +}); diff --git a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx index 5e6f355ee8..557e8c8d73 100644 --- a/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx +++ b/src/components/ContentHighlights/HighlightStepper/tests/ContentHighlightStepper.test.jsx @@ -8,7 +8,12 @@ import { renderWithRouter } from '@edx/frontend-enterprise-utils'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import { ContentHighlightsContext } from '../../ContentHighlightsContext'; -import { BUTTON_TEXT, STEPPER_STEP_TEXT } from '../../data/constants'; +import { + BUTTON_TEXT, + STEPPER_STEP_TEXT, + testCourseAggregation, + testCourseData, +} from '../../data/constants'; import { configuration } from '../../../../config'; import ContentHighlightsDashboard from '../../ContentHighlightsDashboard'; import { EnterpriseAppContext } from '../../../EnterpriseApp/EnterpriseAppContextProvider'; @@ -18,6 +23,7 @@ const mockStore = configureMockStore([thunk]); const initialState = { portalConfiguration: { enterpriseSlug: 'test-enterprise', + enterpriseId: 'test-enterprise-id', }, }; @@ -29,13 +35,6 @@ const initialEnterpriseAppContextValue = { }, }; -const testCourses = { - 'course:HarvardX+CS50x': true, - 'course:HarvardX+CS50P': true, - 'course:HarvardX+CS50W': true, - 'course:HarvardX+CS50AI': true, -}; - const searchClient = algoliasearch( configuration.ALGOLIA.APP_ID, configuration.ALGOLIA.SEARCH_API_KEY, @@ -52,7 +51,7 @@ const ContentHighlightStepperWrapper = ({ isOpen: false, highlightTitle: null, titleStepValidationError: null, - currentSelectedRowIds: testCourses, + currentSelectedRowIds: testCourseAggregation, }, contentHighlights: [], searchClient, @@ -70,6 +69,29 @@ const ContentHighlightStepperWrapper = ({ ); }; +const mockCourseData = [...testCourseData]; +jest.mock('react-instantsearch-dom', () => ({ + ...jest.requireActual('react-instantsearch-dom'), + connectStateResults: Component => function connectStateResults(props) { + return ( + + ); + }, +})); + describe('', () => { it('Displays the stepper', () => { renderWithRouter(); diff --git a/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperConfirmContent.test.jsx b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperConfirmContent.test.jsx new file mode 100644 index 0000000000..64ae044a3b --- /dev/null +++ b/src/components/ContentHighlights/HighlightStepper/tests/HighlightStepperConfirmContent.test.jsx @@ -0,0 +1,125 @@ +import React, { useState } from 'react'; +import { screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import algoliasearch from 'algoliasearch/lite'; +import { renderWithRouter } from '@edx/frontend-enterprise-utils'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; +import HighlightStepperConfirmContent, { BaseReviewContentSelections, SelectedContent } from '../HighlightStepperConfirmContent'; +import { + DEFAULT_ERROR_MESSAGE, + testCourseAggregation, + testCourseData, +} from '../../data/constants'; +import { ContentHighlightsContext } from '../../ContentHighlightsContext'; +import { configuration } from '../../../../config'; + +const mockStore = configureMockStore([thunk]); +const enterpriseId = 'test-enterprise-id'; +const initialState = { + portalConfiguration: + { + enterpriseSlug: 'test-enterprise', + enterpriseId, + }, +}; + +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); + +// eslint-disable-next-line react/prop-types +const HighlightStepperConfirmContentWrapper = ({ children, currentSelectedRowIds = [] }) => { + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds, + }, + contentHighlights: [], + searchClient, + }); + return ( + + + {children} + + + ); +}; + +testCourseData.forEach((element, index) => { + if (!element.objectID) { + testCourseData[index].objectID = index + 1; + } +}); + +const mockCourseData = [...testCourseData]; +jest.mock('react-instantsearch-dom', () => ({ + ...jest.requireActual('react-instantsearch-dom'), + connectStateResults: Component => function connectStateResults(props) { + return ( + + ); + }, +})); + +describe('', () => { + it('renders the content', () => { + renderWithRouter( + + + , + ); + testCourseData.forEach((element) => { + expect(screen.getByText(element.title)).toBeInTheDocument(); + }); + }); +}); + +describe('BaseReviewContentSelections', () => { + it('returns skeleton while search stalled', () => { + renderWithRouter( + + + , + ); + expect(screen.getAllByTestId('card-item-skeleton')).toBeTruthy(); + }); + it('should render selected card content', () => { + renderWithRouter( + + + , + ); + expect(screen.getByTestId('base-content-no-results')).toBeInTheDocument(); + }); +}); + +describe('SelectedContent', () => { + it('should not render anything when nothing is selected', () => { + renderWithRouter( + + + , + ); + expect(screen.getByTestId('selected-content-no-results')).toBeInTheDocument(); + expect(screen.getByText(DEFAULT_ERROR_MESSAGE.EMPTY_SELECTEDROWIDS)).toBeInTheDocument(); + }); +}); diff --git a/src/components/ContentHighlights/SkeletonContentCard.jsx b/src/components/ContentHighlights/SkeletonContentCard.jsx index 913555428c..e35a595d75 100644 --- a/src/components/ContentHighlights/SkeletonContentCard.jsx +++ b/src/components/ContentHighlights/SkeletonContentCard.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Card } from '@edx/paragon'; const SkeletonContentCard = () => ( - + diff --git a/src/components/ContentHighlights/SkeletonContentCardContainer.jsx b/src/components/ContentHighlights/SkeletonContentCardContainer.jsx new file mode 100644 index 0000000000..fcfcca47c9 --- /dev/null +++ b/src/components/ContentHighlights/SkeletonContentCardContainer.jsx @@ -0,0 +1,29 @@ +import PropTypes from 'prop-types'; +import { CardGrid } from '@edx/paragon'; +import { v4 as uuidv4 } from 'uuid'; +import SkeletonContentCard from './SkeletonContentCard'; +import { HIGHLIGHTS_CARD_GRID_COLUMN_SIZES } from './data/constants'; + +const SkeletonContentCardContainer = ({ itemCount, columnSizes }) => ( + + {[ + ...new Array(itemCount), + ].map(() => )}; + +); + +SkeletonContentCardContainer.propTypes = { + itemCount: PropTypes.number.isRequired, + columnSizes: PropTypes.shape({ + xs: PropTypes.number, + md: PropTypes.number, + lg: PropTypes.number, + xl: PropTypes.number, + }), +}; + +SkeletonContentCardContainer.defaultProps = { + columnSizes: HIGHLIGHTS_CARD_GRID_COLUMN_SIZES, +}; + +export default SkeletonContentCardContainer; diff --git a/src/components/ContentHighlights/data/constants.js b/src/components/ContentHighlights/data/constants.js index 5e44ff074c..6b0d9e67dd 100644 --- a/src/components/ContentHighlights/data/constants.js +++ b/src/components/ContentHighlights/data/constants.js @@ -1,6 +1,21 @@ // eslint-disable-next-line import/no-extraneous-dependencies import { faker } from '@faker-js/faker'; +// Default Card Grid columnSizes +export const HIGHLIGHTS_CARD_GRID_COLUMN_SIZES = { + xs: 12, + md: 6, + lg: 4, + xl: 3, +}; +// Empty Content and Error Messages +export const DEFAULT_ERROR_MESSAGE = { + EMPTY_HIGHLIGHT_SET: 'There is no highlighted content for this highlight collection.', + // eslint-disable-next-line quotes + EMPTY_SELECTEDROWIDS: `You don't have any highlighted content selected. Go back to the previous step to select content.`, +}; +// Max highlight sets per enteprise curation +export const MAX_HIGHLIGHT_SETS_PER_ENTERPRISE_CURATION = 8; // Max number of content items per highlight set export const MAX_CONTENT_ITEMS_PER_HIGHLIGHT_SET = 12; // Max length of highlight title in stepper @@ -28,7 +43,10 @@ export const FOOTER_TEXT_BY_CONTENT_TYPE = { learnerpathway: 'Pathway', }; -// Test Data for Content Highlights +// Test Data for Content Highlights From this point onwards +// Test entepriseId for Content Highlights to display card selections and confirmation +export const testEnterpriseId = 'e783bb19-277f-479e-9c41-8b0ed31b4060'; +// Test Content Highlights data export const TEST_COURSE_HIGHLIGHTS_DATA = [ { uuid: faker.datatype.uuid(), @@ -306,3 +324,74 @@ export const TEST_COURSE_HIGHLIGHTS_DATA = [ ], }, ]; + +export const testCourseData = [ + { + aggregationKey: 'course:HarvardX+CS50x', + title: 'CS50s Introduction to Computer Science', + contentType: 'course', + partners: [ + { + name: 'Harvard University', + logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png', + }, + ], + cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/course/image/da1b2400-322b-459b-97b0-0c557f05d017-3b9fb73b5d5d.small.jpg', + originalImageUrl: 'https://prod-discovery.edx-cdn.org/media/course/image/da1b2400-322b-459b-97b0-0c557f05d017-a3d1899c3344.png', + firstEnrollablePaidSeatPrice: 149, + }, + { + aggregationKey: 'course:HarvardX+CS50P', + title: 'CS50s Introduction to Programming with Python', + contentType: 'course', + partners: [ + { + name: 'Harvard University', + logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png', + }, + ], + originalImageUrl: 'https://prod-discovery.edx-cdn.org/media/course/image/2cc794d0-316d-42f7-bbfd-25c34e4cd5df-033e46d516c0.png', + firstEnrollablePaidSeatPrice: 200, + }, + { + aggregationKey: 'course:HarvardX+CS50W', + title: 'CS50s Web Programming with Python and JavaScript', + contentType: 'course', + partners: [ + { + name: 'Harvard University', + logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png', + }, + ], + cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/course/image/8f8e5124-1dab-47e6-8fa6-3fbdc0738f0a-762af069070e.small.jpg', + originalImageUrl: 'https://prod-discovery.edx-cdn.org/media/course/image/8f8e5124-1dab-47e6-8fa6-3fbdc0738f0a-4978ad93b1c3.png', + firstEnrollablePaidSeatPrice: 249, + }, + { + aggregationKey: 'course:HarvardX+CS50AI', + title: 'CS50s Introduction to Artificial Intelligence with Python', + contentType: 'course', + partners: [ + { + name: 'Harvard University', + logoImageUrl: 'https://prod-discovery.edx-cdn.org/organization/logos/44022f13-20df-4666-9111-cede3e5dc5b6-2cc39992c67a.png', + }, + ], + cardImageUrl: 'https://prod-discovery.edx-cdn.org/media/course/image/3a31db71-de8f-45f1-ae65-11981ed9d680-31634d40b3bb.small.png', + originalImageUrl: 'https://prod-discovery.edx-cdn.org/media/course/image/3a31db71-de8f-45f1-ae65-11981ed9d680-b801bb328333.png', + firstEnrollablePaidSeatPrice: 199, + }, +]; +testCourseData.forEach((element, index) => { + if (!element.objectID) { + // added to mimic the objectID prop passed by `connectStateResults` with Algolia + testCourseData[index].objectID = index + 1; + } +}); + +export const testCourseAggregation = { + 'course:HarvardX+CS50x': true, + 'course:HarvardX+CS50P': true, + 'course:HarvardX+CS50W': true, + 'course:HarvardX+CS50AI': true, +}; diff --git a/src/components/ContentHighlights/data/hooks.js b/src/components/ContentHighlights/data/hooks.js index a691eb4be3..f1136fe703 100644 --- a/src/components/ContentHighlights/data/hooks.js +++ b/src/components/ContentHighlights/data/hooks.js @@ -1,5 +1,8 @@ +import { camelCaseObject } from '@edx/frontend-platform'; +import { logError } from '@edx/frontend-platform/logging'; import { useCallback, useState, useEffect } from 'react'; import { useContextSelector } from 'use-context-selector'; +import EnterpriseCatalogApiService from '../../../data/services/EnterpriseCatalogApiService'; import { ContentHighlightsContext } from '../ContentHighlightsContext'; export function useHighlightSetsForCuration(enterpriseCuration) { @@ -30,12 +33,42 @@ export function useHighlightSetsForCuration(enterpriseCuration) { return highlightSets; } +export function useHighlightSet(highlightSetUUID) { + const [highlightSet, setHighlightSet] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const getHighlightSet = useCallback(async () => { + try { + const { data } = await EnterpriseCatalogApiService.fetchHighlightSet(highlightSetUUID); + const result = camelCaseObject(data); + setHighlightSet(result); + } catch (e) { + setError(e); + logError(e); + } finally { + setIsLoading(false); + } + }, [highlightSetUUID]); + + useEffect(() => { + getHighlightSet(); + }, [getHighlightSet]); + + return { + highlightSet, + isLoading, + error, + }; +} + /** * Defines an interface to mutate the `ContentHighlightsContext` context value. */ export function useContentHighlightsContext() { const setState = useContextSelector(ContentHighlightsContext, v => v[1]); - + // eslint-disable-next-line max-len + const currentSelectedRowState = useContextSelector(ContentHighlightsContext, v => v[0].stepperModal.currentSelectedRowIds); const openStepperModal = useCallback(() => { setState(s => ({ ...s, @@ -68,6 +101,20 @@ export function useContentHighlightsContext() { })); }, [setState]); + const deleteSelectedRowId = useCallback((rowId) => { + setState(s => { + const currentRowIds = { ...currentSelectedRowState }; + delete currentRowIds[rowId]; + return { + ...s, + stepperModal: { + ...s.stepperModal, + currentSelectedRowIds: currentRowIds, + }, + }; + }); + }, [setState, currentSelectedRowState]); + const setHighlightTitle = useCallback(({ highlightTitle, titleStepValidationError }) => { setState(s => ({ ...s, @@ -82,6 +129,7 @@ export function useContentHighlightsContext() { return { openStepperModal, resetStepperModal, + deleteSelectedRowId, setCurrentSelectedRowIds, setHighlightTitle, }; diff --git a/src/components/ContentHighlights/data/utils.js b/src/components/ContentHighlights/data/utils.js new file mode 100644 index 0000000000..07a290b5ba --- /dev/null +++ b/src/components/ContentHighlights/data/utils.js @@ -0,0 +1,24 @@ +import { configuration } from '../../../config'; +import { FOOTER_TEXT_BY_CONTENT_TYPE } from './constants'; + +// Highlight Card logic for footer text +export const getContentHighlightCardFooter = ({ price, contentType }) => { + const formattedContentType = FOOTER_TEXT_BY_CONTENT_TYPE[contentType?.toLowerCase()]; + if (!price) { + return formattedContentType; + } + return `$${price} ยท ${formattedContentType}`; +}; + +// Generate URLs for about pages from the enterprise learner portal +export function generateAboutPageUrl({ enterpriseSlug, contentType, contentKey }) { + if (!contentType || !contentKey) { + return undefined; + } + const { ENTERPRISE_LEARNER_PORTAL_URL } = configuration; + const aboutPageBase = `${ENTERPRISE_LEARNER_PORTAL_URL}/${enterpriseSlug}`; + if (contentType === 'learnerpathway') { + return `${aboutPageBase}/search/${contentKey}`; + } + return `${aboutPageBase}/${contentType}/${contentKey}`; +} diff --git a/src/components/ContentHighlights/tests/ContentHighlightSet.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightSet.test.jsx new file mode 100644 index 0000000000..c1aecb2691 --- /dev/null +++ b/src/components/ContentHighlights/tests/ContentHighlightSet.test.jsx @@ -0,0 +1,105 @@ +import algoliasearch from 'algoliasearch/lite'; +import React, { useState } from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; +import Router, { Route } from 'react-router-dom'; +import { renderHook } from '@testing-library/react-hooks/dom'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { camelCaseObject } from '@edx/frontend-platform'; +import { ContentHighlightsContext } from '../ContentHighlightsContext'; +import ContentHighlightSet from '../ContentHighlightSet'; +import { useHighlightSet } from '../data/hooks'; +import { ROUTE_NAMES } from '../../EnterpriseApp/data/constants'; +import EnterpriseCatalogApiService from '../../../data/services/EnterpriseCatalogApiService'; +import { EnterpriseAppContext } from '../../EnterpriseApp/EnterpriseAppContextProvider'; +import { TEST_COURSE_HIGHLIGHTS_DATA } from '../data/constants'; +import { configuration } from '../../../config'; + +jest.mock('../../../data/services/EnterpriseCatalogApiService'); + +const mockHighlightSetResponse = camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA); +const mockStore = configureMockStore([thunk]); +const highlightSetUUID = 'fake-uuid'; +const searchClient = algoliasearch( + configuration.ALGOLIA.APP_ID, + configuration.ALGOLIA.SEARCH_API_KEY, +); + +const initialState = { + portalConfiguration: { + enterpriseSlug: 'test-enterprise', + }, + highlightSetUUID, +}; +const mockDispatchFn = jest.fn(); +const initialEnterpriseAppContextValue = { + enterpriseCuration: { + dispatch: mockDispatchFn, + }, +}; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); + +/* eslint-disable react/prop-types */ +// eslint-disable-next-line no-unused-vars +const ContentHighlightSetWrapper = ( + enterpriseAppContextValue = initialEnterpriseAppContextValue, + { children }, + ...props +) => { + /* eslint-enable react/prop-types */ + const contextValue = useState({ + stepperModal: { + isOpen: false, + highlightTitle: null, + titleStepValidationError: null, + currentSelectedRowIds: {}, + }, + contentHighlights: [], + searchClient, + }); + return ( + + + + + {children} + } + /> + + + + + ); +}; + +describe('', () => { + it('Displays the title of the highlight set', async () => { + jest.spyOn(Router, 'useParams').mockReturnValue({ highlightSetUUID }); + EnterpriseCatalogApiService.fetchHighlightSet.mockResolvedValueOnce({ + data: mockHighlightSetResponse, + }); + const { result, waitForNextUpdate } = renderHook(() => useHighlightSet(highlightSetUUID)); + expect(result.current).toEqual({ + isLoading: true, + error: null, + highlightSet: [], + }); + + await waitForNextUpdate(); + expect(result.current).toEqual({ + isLoading: false, + error: null, + highlightSet: camelCaseObject(TEST_COURSE_HIGHLIGHTS_DATA), + }); + expect( + EnterpriseCatalogApiService.fetchHighlightSet, + ).toHaveBeenCalled(); + }); +}); diff --git a/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx index c6226863ed..ae98a1f7b8 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsCardItemsContainer.test.jsx @@ -8,7 +8,7 @@ import { camelCaseObject } from '@edx/frontend-platform'; import { renderWithRouter } from '@edx/frontend-enterprise-utils'; import ContentHighlightsCardItemsContainer from '../ContentHighlightsCardItemsContainer'; -import { TEST_COURSE_HIGHLIGHTS_DATA } from '../data/constants'; +import { DEFAULT_ERROR_MESSAGE, TEST_COURSE_HIGHLIGHTS_DATA } from '../data/constants'; const mockStore = configureMockStore([thunk]); @@ -27,7 +27,10 @@ const ContentHighlightsCardItemsContainerWrapper = (props) => ( describe('', () => { it('Displays all content data titles', () => { - renderWithRouter(); + renderWithRouter(); const firstTitle = testHighlightSet[0].title; const lastTitle = testHighlightSet[testHighlightSet.length - 1].title; expect(screen.getByText(firstTitle)).toBeInTheDocument(); @@ -35,7 +38,10 @@ describe('', () => { }); it('Displays all content data content types', () => { - renderWithRouter(); + renderWithRouter(); const firstContentType = testHighlightSet[0].contentType; const lastContentType = testHighlightSet[testHighlightSet.length - 1].contentType; expect(screen.getByText(firstContentType)).toBeInTheDocument(); @@ -43,7 +49,10 @@ describe('', () => { }); it('Displays multiple organizations', () => { - renderWithRouter(); + renderWithRouter(); const firstContentType = testHighlightSet[0] .authoringOrganizations[0].name; const lastContentType = testHighlightSet[0] @@ -51,4 +60,19 @@ describe('', () => { expect(screen.getByText(firstContentType, { exact: false })).toBeInTheDocument(); expect(screen.getByText(lastContentType, { exact: false })).toBeInTheDocument(); }); + it('Displays nothing when highlightedContents length equals 0', () => { + renderWithRouter(); + expect(screen.getByTestId('empty-highlighted-content')).toBeInTheDocument(); + expect(screen.getByText(DEFAULT_ERROR_MESSAGE.EMPTY_HIGHLIGHT_SET)).toBeInTheDocument(); + }); + it('Displays Skeleton on load', () => { + renderWithRouter(); + expect(screen.getAllByTestId('card-item-skeleton')).toBeTruthy(); + }); }); diff --git a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx index e9b2b12d0b..2241f15961 100644 --- a/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx +++ b/src/components/ContentHighlights/tests/ContentHighlightsDashboard.test.jsx @@ -19,6 +19,7 @@ const mockStore = configureMockStore([thunk]); const initialState = { portalConfiguration: { enterpriseSlug: 'test-enterprise', + enterpriseId: 'test-enterprise-id', }, }; diff --git a/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx b/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx index e22d9aaef6..e0af0d0a14 100644 --- a/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx +++ b/src/components/ContentHighlights/tests/CurrentContentHighlightItemsHeader.test.jsx @@ -12,7 +12,7 @@ jest.mock('../DeleteHighlightSet', () => ({ })); const highlightSetUUID = 'fake-uuid'; - +const highlightTitle = 'fake-title'; const CurrentContentHighlightItemsHeaderWrapper = (props) => ( ', () => { it('Displays all content data titles', () => { const initialRouterEntry = `/test-enterprise/admin/content-highlights/${highlightSetUUID}`; renderWithRouter( - , + , { route: initialRouterEntry }, ); - expect(screen.getByText(highlightSetUUID)).toBeInTheDocument(); + expect(screen.getByText(highlightTitle)).toBeInTheDocument(); expect(screen.getByTestId('deleteHighlightSet')).toBeInTheDocument(); }); + it('Displays Skeleton on load', () => { + const initialRouterEntry = `/test-enterprise/admin/content-highlights/${highlightSetUUID}`; + renderWithRouter( + , + { route: initialRouterEntry }, + ); + expect(screen.queryByText(highlightTitle)).not.toBeInTheDocument(); + expect(screen.getByTestId('header-skeleton')).toBeInTheDocument(); + }); }); diff --git a/src/data/services/EnterpriseCatalogApiService.js b/src/data/services/EnterpriseCatalogApiService.js index 3f20aeceab..ed2b94313c 100644 --- a/src/data/services/EnterpriseCatalogApiService.js +++ b/src/data/services/EnterpriseCatalogApiService.js @@ -42,6 +42,10 @@ class EnterpriseCatalogApiService { return EnterpriseCatalogApiService.apiClient().get(`${EnterpriseCatalogApiService.enterpriseCurationUrl}?${queryParams.toString()}`); } + static fetchHighlightSet(highlightSetUUID) { + return EnterpriseCatalogApiService.apiClient().get(`${EnterpriseCatalogApiService.highlightSetUrl}${highlightSetUUID}`); + } + static createEnterpriseCurationConfig(enterpriseId, options = {}) { const payload = { enterprise_customer: enterpriseId, diff --git a/src/setupTest.js b/src/setupTest.js index fd1d014d05..92011cdf9e 100644 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -6,7 +6,7 @@ import Adapter from 'enzyme-adapter-react-16'; import MockAdapter from 'axios-mock-adapter'; import ResizeObserverPolyfill from 'resize-observer-polyfill'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - +import 'jest-canvas-mock'; import 'jest-localstorage-mock'; Enzyme.configure({ adapter: new Adapter() });