diff --git a/jest.config.js b/jest.config.js index 4e5f6ce264..dc01634773 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ const { createConfig } = require('@edx/frontend-build'); module.exports = createConfig('jest', { setupFilesAfterEnv: [ + 'jest-expect-message', '/src/setupTest.js', ], coveragePathIgnorePatterns: [ diff --git a/package-lock.json b/package-lock.json index 1829011ac7..959afe5efc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "glob": "7.2.3", "husky": "7.0.4", "jest-canvas-mock": "^2.5.2", + "jest-expect-message": "^1.1.3", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "ts-loader": "^9.5.0" @@ -18701,6 +18702,12 @@ "node": ">= 10.14.2" } }, + "node_modules/jest-expect-message": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/jest-expect-message/-/jest-expect-message-1.1.3.tgz", + "integrity": "sha512-bTK77T4P+zto+XepAX3low8XVQxDgaEqh3jSTQOG8qvPpD69LsIdyJTa+RmnJh3HNSzJng62/44RPPc7OIlFxg==", + "dev": true + }, "node_modules/jest-get-type": { "version": "26.3.0", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", diff --git a/package.json b/package.json index 70be47c028..df907cdd2d 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "glob": "7.2.3", "husky": "7.0.4", "jest-canvas-mock": "^2.5.2", + "jest-expect-message": "^1.1.3", "react-test-renderer": "17.0.2", "reactifex": "1.1.1", "ts-loader": "^9.5.0" diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 678b721093..d2d2efa911 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -7,6 +7,7 @@ import { Button, Container, Layout, + Row, TransitionReplace, } from '@edx/paragon'; import { Helmet } from 'react-helmet'; @@ -22,6 +23,7 @@ import { ErrorAlert, } from '@edx/frontend-lib-content-components'; +import { LoadingSpinner } from '../generic/Loading'; import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import { RequestStatus } from '../data/constants'; import SubHeader from '../generic/sub-header/SubHeader'; @@ -35,6 +37,7 @@ import StatusBar from './status-bar/StatusBar'; import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal'; import SectionCard from './section-card/SectionCard'; import SubsectionCard from './subsection-card/SubsectionCard'; +import UnitCard from './unit-card/UnitCard'; import HighlightsModal from './highlights-modal/HighlightsModal'; import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import PublishModal from './publish-modal/PublishModal'; @@ -83,8 +86,11 @@ const CourseOutline = ({ courseId }) => { handleDeleteItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, + handleDuplicateUnitSubmit, handleNewSectionSubmit, handleNewSubsectionSubmit, + handleNewUnitSubmit, + getUnitUrl, handleDragNDrop, } = useCourseOutline({ courseId }); @@ -109,7 +115,11 @@ const CourseOutline = ({ courseId }) => { if (isLoading) { // eslint-disable-next-line react/jsx-no-useless-fragment - return <>; + return ( + + + + ); } return ( @@ -207,7 +217,23 @@ const CourseOutline = ({ courseId }) => { onOpenDeleteModal={openDeleteModal} onEditSubmit={handleEditSubmit} onDuplicateSubmit={handleDuplicateSubsectionSubmit} - /> + onNewUnitSubmit={handleNewUnitSubmit} + > + {subsection.childInfo.children.map((unit) => ( + + ))} + ))} diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 886cc45dc5..da2f0321f0 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -2,6 +2,7 @@ @import "./status-bar/StatusBar"; @import "./section-card/SectionCard"; @import "./subsection-card/SubsectionCard"; +@import "./unit-card/UnitCard"; @import "./card-header/CardHeader"; @import "./empty-placeholder/EmptyPlaceholder"; @import "./highlights-modal/HighlightsModal"; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 0f4ee33e17..003ffd0e3b 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -36,6 +36,7 @@ import { courseSubsectionMock, } from './__mocks__'; import { executeThunk } from '../utils'; +import { COURSE_BLOCK_NAMES } from './constants'; import CourseOutline from './CourseOutline'; import messages from './messages'; import headerMessages from './header-navigations/messages'; @@ -120,9 +121,7 @@ describe('', () => { .onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink)) .reply(500); const reindexButton = await findByTestId('course-reindex'); - await act(async () => { - fireEvent.click(reindexButton); - }); + await act(async () => fireEvent.click(reindexButton)); expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument(); }); @@ -145,9 +144,7 @@ describe('', () => { .onGet(getXBlockApiUrl(courseSectionMock.id)) .reply(200, courseSectionMock); const newSectionButton = await findByTestId('new-section-button'); - await act(async () => { - fireEvent.click(newSectionButton); - }); + await act(async () => fireEvent.click(newSectionButton)); elements = await findAllByTestId('section-card'); expect(elements.length).toBe(5); @@ -182,6 +179,32 @@ describe('', () => { expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled(); }); + it('adds new unit correctly', async () => { + const { findAllByTestId } = render(); + const [sectionElement] = await findAllByTestId('section-card'); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const units = await within(subsectionElement).findAllByTestId('unit-card'); + expect(units.length).toBe(1); + + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: 'some', + }); + const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button'); + await act(async () => fireEvent.click(newUnitButton)); + expect(axiosMock.history.post.length).toBe(1); + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [subsection] = section.childInfo.children; + expect(axiosMock.history.post[0].data).toBe(JSON.stringify({ + parent_locator: subsection.id, + category: COURSE_BLOCK_NAMES.vertical.id, + display_name: COURSE_BLOCK_NAMES.vertical.name, + })); + }); + it('render checklist value correctly', async () => { const { getByText } = render(); @@ -232,9 +255,7 @@ describe('', () => { const enableButton = await findByTestId('highlights-enable-button'); fireEvent.click(enableButton); const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage); - await act(async () => { - fireEvent.click(saveButton); - }); + await act(async () => fireEvent.click(saveButton)); expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument(); }); @@ -271,177 +292,272 @@ describe('', () => { }); }); - it('check edit section when edit query is successfully', async () => { - const { findAllByTestId, findByText } = render(); - const newDisplayName = 'New section name'; - - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - - axiosMock - .onPost(getCourseItemApiUrl(section.id, { + it('check edit title works for section, subsection and unit', async () => { + const { findAllByTestId } = render(); + const checkEditTitle = async (section, element, item, newName, elementName) => { + axiosMock.reset(); + axiosMock + .onPost(getCourseItemApiUrl(item.id, { + metadata: { + display_name: newName, + }, + })) + .reply(200, { dummy: 'value' }); + // mock section, subsection and unit name and check within the elements. + // this is done to avoid adding conditions to this mock. + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + display_name: newName, + childInfo: { + children: [ + { + ...section.childInfo.children[0], + display_name: newName, + childInfo: { + children: [ + { + ...section.childInfo.children[0].childInfo.children[0], + display_name: newName, + }, + ], + }, + }, + ], + }, + }); + + const editButton = await within(element).findByTestId(`${elementName}-edit-button`); + fireEvent.click(editButton); + const editField = await within(element).findByTestId(`${elementName}-edit-field`); + fireEvent.change(editField, { target: { value: newName } }); + await act(async () => fireEvent.blur(editField)); + expect( + axiosMock.history.post[axiosMock.history.post.length - 1].data, + `Failed for ${elementName}!`, + ).toBe(JSON.stringify({ metadata: { - display_name: newDisplayName, + display_name: newName, }, - })) - .reply(200, { dummy: 'value' }); - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, { - ...section, - display_name: newDisplayName, - }); - - const [sectionElement] = await findAllByTestId('section-card'); - const editButton = await within(sectionElement).findByTestId('section-edit-button'); - fireEvent.click(editButton); - const editField = await within(sectionElement).findByTestId('section-edit-field'); - fireEvent.change(editField, { target: { value: newDisplayName } }); - await act(async () => { - fireEvent.blur(editField); - }); - - expect(await findByText(newDisplayName)).toBeInTheDocument(); - }); + })); + const results = await within(element).findAllByText(newName); + expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0); + }; - it('check whether section is deleted when delete button is clicked', async () => { - const { findAllByTestId, findByTestId, queryByText } = render(); + // check section const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - await waitFor(() => { - expect(queryByText(section.displayName)).toBeInTheDocument(); - }); - - axiosMock.onDelete(getCourseItemApiUrl(section.id)).reply(200); - const [sectionElement] = await findAllByTestId('section-card'); - const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); - const deleteButton = await within(sectionElement).findByTestId('section-card-header__menu-delete-button'); - fireEvent.click(deleteButton); - const confirmButton = await findByTestId('delete-confirm-button'); - await act(async () => { - fireEvent.click(confirmButton); - }); + await checkEditTitle(section, sectionElement, section, 'New section name', 'section'); - await waitFor(() => { - expect(queryByText(section.displayName)).not.toBeInTheDocument(); - }); + // check subsection + const [subsection] = section.childInfo.children; + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection'); + + // check unit + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit'); }); - it('check whether subsection is deleted when delete button is clicked', async () => { + it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => { const { findAllByTestId, findByTestId, queryByText } = render(); + // get section, subsection and unit const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - const [subsection] = section.childInfo.children; - await waitFor(() => { - expect(queryByText(subsection.displayName)).toBeInTheDocument(); - }); - - axiosMock.onDelete(getCourseItemApiUrl(subsection.id)).reply(200); - const [sectionElement] = await findAllByTestId('section-card'); + const [subsection] = section.childInfo.children; const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); - const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button'); - fireEvent.click(menu); - const deleteButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-delete-button'); - fireEvent.click(deleteButton); - const confirmButton = await findByTestId('delete-confirm-button'); - await act(async () => { - fireEvent.click(confirmButton); - }); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); - await waitFor(() => { - expect(queryByText(subsection.displayName)).not.toBeInTheDocument(); - }); - }); + const checkDeleteBtn = async (item, element, elementName) => { + await waitFor(() => { + expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument(); + }); - it('check whether section is duplicated successfully', async () => { - const { findAllByTestId } = render(); - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; - expect(await findAllByTestId('section-card')).toHaveLength(4); + axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200); - axiosMock - .onPost(getXBlockBaseApiUrl()) - .reply(200, { - locator: courseSectionMock.id, - }); - section.id = courseSectionMock.id; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, { - ...section, - }); + const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`); + fireEvent.click(menu); + const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`); + fireEvent.click(deleteButton); + const confirmButton = await findByTestId('delete-confirm-button'); + await act(async () => fireEvent.click(confirmButton)); - const [sectionElement] = await findAllByTestId('section-card'); - const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); - const duplicateButton = await within(sectionElement).findByTestId('section-card-header__menu-duplicate-button'); - await act(async () => { - fireEvent.click(duplicateButton); - }); - expect(await findAllByTestId('section-card')).toHaveLength(5); + await waitFor(() => { + expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument(); + }); + }; + + // delete unit, subsection and then section in order. + // check unit + await checkDeleteBtn(unit, unitElement, 'unit'); + // check subsection + await checkDeleteBtn(subsection, subsectionElement, 'subsection'); + // check section + await checkDeleteBtn(section, sectionElement, 'section'); }); - it('check whether subsection is duplicated successfully', async () => { + it('check whether section, subsection and unit is duplicated successfully', async () => { const { findAllByTestId } = render(); - const section = courseOutlineIndexMock.courseStructure.childInfo.children[0]; - let [sectionElement] = await findAllByTestId('section-card'); + // get section, subsection and unit + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + const [sectionElement] = await findAllByTestId('section-card'); const [subsection] = section.childInfo.children; - let subsections = await within(sectionElement).findAllByTestId('subsection-card'); - expect(subsections.length).toBe(1); - - axiosMock - .onPost(getXBlockBaseApiUrl()) - .reply(200, { - locator: courseSubsectionMock.id, - }); - subsection.id = courseSubsectionMock.id; - section.childInfo.children = [...section.childInfo.children, subsection]; - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, { - ...section, - }); - - const menu = await within(subsections[0]).findByTestId('subsection-card-header__menu-button'); - fireEvent.click(menu); - const duplicateButton = await within(subsections[0]).findByTestId('subsection-card-header__menu-duplicate-button'); - await act(async () => { - fireEvent.click(duplicateButton); - }); + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + const checkDuplicateBtn = async (item, parentElement, element, elementName, expectedLength) => { + // baseline + if (parentElement) { + expect( + await within(parentElement).findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength - 1); + } else { + expect( + await findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength - 1); + } - [sectionElement] = await findAllByTestId('section-card'); - subsections = await within(sectionElement).findAllByTestId('subsection-card'); - expect(subsections.length).toBe(2); + const duplicatedItemId = item.id + elementName; + axiosMock + .onPost(getXBlockBaseApiUrl()) + .reply(200, { + locator: duplicatedItemId, + }); + if (elementName === 'section') { + section.id = duplicatedItemId; + } else if (elementName === 'subsection') { + section.childInfo.children = [...section.childInfo.children, { ...subsection, id: duplicatedItemId }]; + } else if (elementName === 'unit') { + subsection.childInfo.children = [...subsection.childInfo.children, { ...unit, id: duplicatedItemId }]; + section.childInfo.children = [subsection, ...section.childInfo.children.slice(1)]; + } + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, { + ...section, + }); + + const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`); + fireEvent.click(menu); + const duplicateButton = await within(element).findByTestId(`${elementName}-card-header__menu-duplicate-button`); + await act(async () => fireEvent.click(duplicateButton)); + if (parentElement) { + expect( + await within(parentElement).findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength); + } else { + expect( + await findAllByTestId(`${elementName}-card`), + `Failed for ${elementName}!`, + ).toHaveLength(expectedLength); + } + }; + + // duplicate unit, subsection and then section in order. + // check unit + await checkDuplicateBtn(unit, subsectionElement, unitElement, 'unit', 2); + // check subsection + await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 2); + // check section + await checkDuplicateBtn(section, null, sectionElement, 'section', 5); }); - it('check section is published when publish button is clicked', async () => { - const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; + it('check section, subsection & unit is published when publish button is clicked', async () => { const { findAllByTestId, findByTestId } = render(); - - axiosMock - .onPost(getCourseItemApiUrl(section.id), { - publish: 'make_public', - }) - .reply(200, { dummy: 'value' }); - - axiosMock - .onGet(getXBlockApiUrl(section.id)) - .reply(200, { - ...section, - published: true, - releasedToStudents: false, - }); - + const [section] = courseOutlineIndexMock.courseStructure.childInfo.children; const [sectionElement] = await findAllByTestId('section-card'); - const menu = await within(sectionElement).findByTestId('section-card-header__menu-button'); - fireEvent.click(menu); - const publishButton = await within(sectionElement).findByTestId('section-card-header__menu-publish-button'); - await act(async () => fireEvent.click(publishButton)); - const confirmButton = await findByTestId('publish-confirm-button'); - await act(async () => fireEvent.click(confirmButton)); - - expect( - sectionElement.querySelector('.item-card-header__badge-status'), - ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage); + const [subsection] = section.childInfo.children; + const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); + const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn'); + fireEvent.click(expandBtn); + const [unit] = subsection.childInfo.children; + const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); + + const checkPublishBtn = async (item, element, elementName) => { + expect( + await within(element).findByTestId(`${elementName}-card-header__badge-status`), + `Failed for ${elementName}!`, + ).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage); + + axiosMock + .onPost(getCourseItemApiUrl(item.id), { + publish: 'make_public', + }) + .reply(200, { dummy: 'value' }); + + let mockReturnValue = { ...section, published: true }; + if (elementName === 'subsection') { + mockReturnValue = { + ...section, + childInfo: { + children: [ + { + ...section.childInfo.children[0], + published: true, + }, + ...section.childInfo.children.slice(1), + ], + }, + }; + } else if (elementName === 'unit') { + mockReturnValue = { + ...section, + childInfo: { + children: [ + { + ...section.childInfo.children[0], + childInfo: { + children: [ + { + ...section.childInfo.children[0].childInfo.children[0], + published: true, + }, + ...section.childInfo.children[0].childInfo.children.slice(1), + ], + }, + }, + ...section.childInfo.children.slice(1), + ], + }, + }; + } + axiosMock + .onGet(getXBlockApiUrl(section.id)) + .reply(200, mockReturnValue); + + const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`); + fireEvent.click(menu); + const publishButton = await within(element).findByTestId(`${elementName}-card-header__menu-publish-button`); + await act(async () => fireEvent.click(publishButton)); + const confirmButton = await findByTestId('publish-confirm-button'); + await act(async () => fireEvent.click(confirmButton)); + + expect( + await within(element).findByTestId(`${elementName}-card-header__badge-status`), + `Failed for ${elementName}!`, + ).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage); + }; + + // publish unit, subsection and then section in order. + // check unit + await checkPublishBtn(unit, unitElement, 'unit'); + // check subsection + await checkPublishBtn(subsection, subsectionElement, 'subsection'); + // check section + await checkPublishBtn(section, sectionElement, 'section'); }); it('check configure section when configure query is successful', async () => { diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index d7ed4ed35f..54d7d2df75 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -75,7 +75,7 @@ module.exports = { published: false, publishedOn: 'Aug 23, 2023 at 12:35 UTC', studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b', - releasedToStudents: true, + releasedToStudents: false, releaseDate: 'Aug 10, 2023 at 22:00 UTC', visibilityState: 'staff_only', hasExplicitStaffLock: true, @@ -137,10 +137,10 @@ module.exports = { category: 'sequential', hasChildren: true, editedOn: 'Jul 07, 2023 at 11:14 UTC', - published: true, + published: false, publishedOn: 'Jul 07, 2023 at 11:14 UTC', studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40edx_introduction', - releasedToStudents: true, + releasedToStudents: false, releaseDate: 'Jan 01, 1970 at 05:00 UTC', visibilityState: 'staff_only', hasExplicitStaffLock: false, @@ -207,10 +207,10 @@ module.exports = { category: 'vertical', hasChildren: true, editedOn: 'Jul 07, 2023 at 11:14 UTC', - published: true, + published: false, publishedOn: 'Jul 07, 2023 at 11:14 UTC', studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc', - releasedToStudents: true, + releasedToStudents: false, releaseDate: 'Jan 01, 1970 at 05:00 UTC', visibilityState: 'staff_only', hasExplicitStaffLock: false, diff --git a/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx b/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx new file mode 100644 index 0000000000..e1cc504f6a --- /dev/null +++ b/src/course-outline/card-header/BaseTitleWithStatusBadge.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, Truncate } from '@edx/paragon'; +import classNames from 'classnames'; +import { ITEM_BADGE_STATUS } from '../constants'; +import { getItemStatusBadgeContent } from '../utils'; +import messages from './messages'; + +const BaseTitleWithStatusBadge = ({ + title, + status, + namePrefix, +}) => { + const intl = useIntl(); + const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); + + return ( + <> + {title} + {badgeTitle && ( +
+ {badgeIcon && ( + + )} + {badgeTitle} +
+ )} + + ); +}; + +BaseTitleWithStatusBadge.propTypes = { + title: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + namePrefix: PropTypes.string.isRequired, +}; + +export default BaseTitleWithStatusBadge; diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 2c06e02220..9c348d503e 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -2,50 +2,40 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { - Button, Dropdown, Form, Icon, IconButton, - OverlayTrigger, - Tooltip, - Truncate, } from '@edx/paragon'; import { - ArrowDropDown as ArrowDownIcon, - ArrowDropUp as ArrowUpIcon, MoreVert as MoveVertIcon, EditOutline as EditIcon, } from '@edx/paragon/icons'; -import classNames from 'classnames'; import { useEscapeClick } from '../../hooks'; import { ITEM_BADGE_STATUS } from '../constants'; -import { getItemStatusBadgeContent } from '../utils'; import messages from './messages'; const CardHeader = ({ title, status, hasChanges, - isExpanded, onClickPublish, onClickConfigure, onClickMenuButton, onClickEdit, - onExpand, isFormOpen, onEditSubmit, closeForm, isDisabledEditField, onClickDelete, onClickDuplicate, + titleComponent, namePrefix, }) => { const intl = useIntl(); const [titleValue, setTitleValue] = useState(title); - const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl); const isDisabledPublish = (status === ITEM_BADGE_STATUS.live || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; @@ -78,39 +68,7 @@ const CardHeader = ({ /> ) : ( - - {intl.formatMessage(messages.expandTooltip)} - - )} - > - - + titleComponent )}
{!isFormOpen && ( @@ -168,8 +126,6 @@ CardHeader.propTypes = { title: PropTypes.string.isRequired, status: PropTypes.string.isRequired, hasChanges: PropTypes.bool.isRequired, - isExpanded: PropTypes.bool.isRequired, - onExpand: PropTypes.func.isRequired, onClickPublish: PropTypes.func.isRequired, onClickConfigure: PropTypes.func.isRequired, onClickMenuButton: PropTypes.func.isRequired, @@ -180,6 +136,7 @@ CardHeader.propTypes = { isDisabledEditField: PropTypes.bool.isRequired, onClickDelete: PropTypes.func.isRequired, onClickDuplicate: PropTypes.func.isRequired, + titleComponent: PropTypes.node.isRequired, namePrefix: PropTypes.string.isRequired, }; diff --git a/src/course-outline/card-header/CardHeader.scss b/src/course-outline/card-header/CardHeader.scss index 12744ba9f6..a6ba83687a 100644 --- a/src/course-outline/card-header/CardHeader.scss +++ b/src/course-outline/card-header/CardHeader.scss @@ -3,10 +3,10 @@ align-items: center; margin-right: -.5rem; - .item-card-header__expanded-btn { + .item-card-header__title-btn { justify-content: flex-start; padding: 0; - width: 80%; + width: fit-content; height: 1.5rem; margin-right: .25rem; background: transparent; diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.jsx index 704d69baad..f185a3c402 100644 --- a/src/course-outline/card-header/CardHeader.test.jsx +++ b/src/course-outline/card-header/CardHeader.test.jsx @@ -4,6 +4,8 @@ import { IntlProvider } from '@edx/frontend-platform/i18n'; import { ITEM_BADGE_STATUS } from '../constants'; import CardHeader from './CardHeader'; +import BaseTitleWithStatusBadge from './BaseTitleWithStatusBadge'; +import TitleButton from './TitleButton'; import messages from './messages'; const onExpandMock = jest.fn(); @@ -18,8 +20,6 @@ const cardHeaderProps = { title: 'Some title', status: ITEM_BADGE_STATUS.live, hasChanges: false, - isExpanded: true, - onExpand: onExpandMock, onClickMenuButton: onClickMenuButtonMock, onClickPublish: onClickPublishMock, onClickEdit: onClickEditMock, @@ -32,14 +32,33 @@ const cardHeaderProps = { namePrefix: 'section', }; -const renderComponent = (props) => render( - - { + const titleComponent = ( + - , -); + > + + + ); + + return render( + + + , + ); +}; describe('', () => { it('render CardHeader component correctly', async () => { diff --git a/src/course-outline/card-header/TitleButton.jsx b/src/course-outline/card-header/TitleButton.jsx new file mode 100644 index 0000000000..44e891a41a --- /dev/null +++ b/src/course-outline/card-header/TitleButton.jsx @@ -0,0 +1,59 @@ +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + OverlayTrigger, + Tooltip, +} from '@edx/paragon'; +import { + ArrowDropDown as ArrowDownIcon, + ArrowDropUp as ArrowUpIcon, +} from '@edx/paragon/icons'; +import messages from './messages'; + +const TitleButton = ({ + isExpanded, + onTitleClick, + namePrefix, + children, +}) => { + const intl = useIntl(); + const titleTooltipMessage = intl.formatMessage(messages.expandTooltip); + + return ( + + {titleTooltipMessage} + + )} + > + + + ); +}; + +TitleButton.defaultProps = { + children: null, +}; + +TitleButton.propTypes = { + isExpanded: PropTypes.bool.isRequired, + onTitleClick: PropTypes.func.isRequired, + namePrefix: PropTypes.string.isRequired, + children: PropTypes.node, +}; + +export default TitleButton; diff --git a/src/course-outline/card-header/TitleLink.jsx b/src/course-outline/card-header/TitleLink.jsx new file mode 100644 index 0000000000..4a27d11cdb --- /dev/null +++ b/src/course-outline/card-header/TitleLink.jsx @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { Button } from '@edx/paragon'; + +const TitleLink = ({ + titleLink, + namePrefix, + children, +}) => ( + +); + +TitleLink.defaultProps = { + children: null, +}; + +TitleLink.propTypes = { + titleLink: PropTypes.string.isRequired, + namePrefix: PropTypes.string.isRequired, + children: PropTypes.node, +}; + +export default TitleLink; diff --git a/src/course-outline/data/slice.js b/src/course-outline/data/slice.js index bd408c958e..edb8b1e4a5 100644 --- a/src/course-outline/data/slice.js +++ b/src/course-outline/data/slice.js @@ -121,6 +121,23 @@ const slice = createSlice({ return section; }); }, + deleteUnit: (state, { payload }) => { + state.sectionsList = state.sectionsList.map((section) => { + if (section.id !== payload.sectionId) { + return section; + } + section.childInfo.children = section.childInfo.children.map((subsection) => { + if (subsection.id !== payload.subsectionId) { + return subsection; + } + subsection.childInfo.children = subsection.childInfo.children.filter( + ({ id }) => id !== payload.itemId, + ); + return subsection; + }); + return section; + }); + }, duplicateSection: (state, { payload }) => { state.sectionsList = state.sectionsList.reduce((result, currentValue) => { if (currentValue.id === payload.id) { @@ -149,6 +166,7 @@ export const { setCurrentSubsection, deleteSection, deleteSubsection, + deleteUnit, duplicateSection, reorderSectionList, } = slice.actions; diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 50909f5a90..959f25111e 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -39,6 +39,7 @@ import { updateFetchSectionLoadingStatus, deleteSection, deleteSubsection, + deleteUnit, duplicateSection, reorderSectionList, } from './slice'; @@ -264,6 +265,15 @@ export function deleteCourseSubsectionQuery(subsectionId, sectionId) { }; } +export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) { + return async (dispatch) => { + dispatch(deleteCourseItemQuery( + unitId, + () => deleteUnit({ itemId: unitId, subsectionId, sectionId }), + )); + }; +} + /** * Generic function to duplicate any course item. See wrapper functions below for specific implementations. * @param {string} itemId @@ -316,6 +326,16 @@ export function duplicateSubsectionQuery(subsectionId, sectionId) { }; } +export function duplicateUnitQuery(unitId, subsectionId, sectionId) { + return async (dispatch) => { + dispatch(duplicateCourseItemQuery( + unitId, + subsectionId, + async () => dispatch(fetchCourseSectionQuery(sectionId, true)), + )); + }; +} + /** * Generic function to add any course item. See wrapper functions below for specific implementations. * @param {string} parentLocator @@ -336,10 +356,7 @@ function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) displayName, ).then(async (result) => { if (result) { - const data = await getCourseItem(result.locator); - // Page should scroll to newly created item. - data.shouldScroll = true; - dispatch(addItemFn(data)); + await addItemFn(result); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(hideProcessingNotification()); } @@ -357,7 +374,12 @@ export function addNewSectionQuery(parentLocator) { parentLocator, COURSE_BLOCK_NAMES.chapter.id, COURSE_BLOCK_NAMES.chapter.name, - (data) => addSection(data), + async (result) => { + const data = await getCourseItem(result.locator); + // Page should scroll to newly created section. + data.shouldScroll = true; + dispatch(addSection(data)); + }, )); }; } @@ -368,7 +390,23 @@ export function addNewSubsectionQuery(parentLocator) { parentLocator, COURSE_BLOCK_NAMES.sequential.id, COURSE_BLOCK_NAMES.sequential.name, - (data) => addSubsection({ parentLocator, data }), + async (result) => { + const data = await getCourseItem(result.locator); + // Page should scroll to newly created subsection. + data.shouldScroll = true; + dispatch(addSubsection({ parentLocator, data })); + }, + )); + }; +} + +export function addNewUnitQuery(parentLocator, callback) { + return async (dispatch) => { + dispatch(addNewCourseItemQuery( + parentLocator, + COURSE_BLOCK_NAMES.vertical.id, + COURSE_BLOCK_NAMES.vertical.name, + async (result) => callback(result.locator), )); }; } diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index ec361ce6ce..7bc5ab9e52 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useToggle } from '@edx/paragon'; +import { useNavigate } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; import { RequestStatus } from '../data/constants'; import { COURSE_BLOCK_NAMES } from './constants'; @@ -22,11 +24,14 @@ import { import { addNewSectionQuery, addNewSubsectionQuery, + addNewUnitQuery, deleteCourseSectionQuery, deleteCourseSubsectionQuery, + deleteCourseUnitQuery, editCourseItemQuery, duplicateSectionQuery, duplicateSubsectionQuery, + duplicateUnitQuery, enableCourseHighlightsEmailsQuery, fetchCourseBestPracticesQuery, fetchCourseLaunchQuery, @@ -40,6 +45,7 @@ import { const useCourseOutline = ({ courseId }) => { const dispatch = useDispatch(); + const navigate = useNavigate(); const { reindexLink, courseStructure, lmsLink } = useSelector(getOutlineIndexData); const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); @@ -68,6 +74,26 @@ const useCourseOutline = ({ courseId }) => { dispatch(addNewSubsectionQuery(sectionId)); }; + const getUnitUrl = (locator) => { + if (process.env.ENABLE_UNIT_PAGE === 'true') { + return `/course/container/${locator}`; + } + return `${getConfig().STUDIO_BASE_URL}/container/${locator}`; + }; + + const openUnitPage = (locator) => { + const url = getUnitUrl(locator); + if (process.env.ENABLE_UNIT_PAGE === 'true') { + navigate(url); + } else { + window.location.assign(url); + } + }; + + const handleNewUnitSubmit = (subsectionId) => { + dispatch(addNewUnitQuery(subsectionId, openUnitPage)); + }; + const headerNavigationsActions = { handleNewSection: handleNewSectionSubmit, handleReIndex: () => { @@ -132,7 +158,11 @@ const useCourseOutline = ({ courseId }) => { dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id)); break; case COURSE_BLOCK_NAMES.vertical.id: - // delete unit + dispatch(deleteCourseUnitQuery( + currentItem.id, + currentSubsection.id, + currentSection.id, + )); break; default: return; @@ -148,6 +178,10 @@ const useCourseOutline = ({ courseId }) => { dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id)); }; + const handleDuplicateUnitSubmit = () => { + dispatch(duplicateUnitQuery(currentItem.id, currentSubsection.id, currentSection.id)); + }; + const handleDragNDrop = (newListId, restoreCallback) => { dispatch(setSectionOrderListQuery(courseId, newListId, restoreCallback)); }; @@ -205,8 +239,12 @@ const useCourseOutline = ({ courseId }) => { handleDeleteItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, + handleDuplicateUnitSubmit, handleNewSectionSubmit, handleNewSubsectionSubmit, + getUnitUrl, + openUnitPage, + handleNewUnitSubmit, handleDragNDrop, }; }; diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index 8b6730d37b..0cbd2ac882 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -10,6 +10,8 @@ import { Add as IconAdd } from '@edx/paragon/icons'; import { setCurrentItem, setCurrentSection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; +import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; +import TitleButton from '../card-header/TitleButton'; import { getItemStatus, scrollToElement } from '../utils'; import messages from './messages'; @@ -31,6 +33,7 @@ const SectionCard = ({ const dispatch = useDispatch(); const [isExpanded, setIsExpanded] = useState(isSectionsExpanded); const [isFormOpen, openForm, closeForm] = useToggle(false); + const namePrefix = 'section'; useEffect(() => { setIsExpanded(isSectionsExpanded); @@ -96,6 +99,20 @@ const SectionCard = ({ } }, [savingStatus]); + const titleComponent = ( + + + + ); + return (
diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 2dbf1e5e01..8e44ac785a 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -8,6 +8,8 @@ import { Add as IconAdd } from '@edx/paragon/icons'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; +import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; +import TitleButton from '../card-header/TitleButton'; import { getItemStatus, scrollToElement } from '../utils'; import messages from './messages'; @@ -20,12 +22,14 @@ const SubsectionCard = ({ savingStatus, onOpenDeleteModal, onDuplicateSubmit, + onNewUnitSubmit, }) => { const currentRef = useRef(null); const intl = useIntl(); const dispatch = useDispatch(); const [isExpanded, setIsExpanded] = useState(false); const [isFormOpen, openForm, closeForm] = useToggle(false); + const namePrefix = 'subsection'; const { id, @@ -65,6 +69,22 @@ const SubsectionCard = ({ closeForm(); }; + const handleNewButtonClick = () => onNewUnitSubmit(id); + + const titleComponent = ( + + + + ); + useEffect(() => { // if this items has been newly added, scroll to it. // we need to check section.shouldScroll as whole section is fetched when a @@ -86,8 +106,6 @@ const SubsectionCard = ({ title={displayName} status={subsectionStatus} hasChanges={hasChanges} - isExpanded={isExpanded} - onExpand={handleExpandContent} onClickMenuButton={handleClickMenuButton} onClickPublish={onOpenPublishModal} onClickEdit={openForm} @@ -97,23 +115,23 @@ const SubsectionCard = ({ onEditSubmit={handleEditSubmit} isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS} onClickDuplicate={onDuplicateSubmit} - namePrefix="subsection" + titleComponent={titleComponent} + namePrefix={namePrefix} /> {isExpanded && ( - <> -
- {children} -
+
+ {children} - +
)}
); @@ -152,6 +170,7 @@ SubsectionCard.propTypes = { savingStatus: PropTypes.string.isRequired, onOpenDeleteModal: PropTypes.func.isRequired, onDuplicateSubmit: PropTypes.func.isRequired, + onNewUnitSubmit: PropTypes.func.isRequired, }; export default SubsectionCard; diff --git a/src/course-outline/subsection-card/SubsectionCard.scss b/src/course-outline/subsection-card/SubsectionCard.scss index c9c0afc74e..0f0bf3ab12 100644 --- a/src/course-outline/subsection-card/SubsectionCard.scss +++ b/src/course-outline/subsection-card/SubsectionCard.scss @@ -9,6 +9,10 @@ margin: $spacer; } + .subsection-card__units { + padding-top: $spacer; + } + .item-card-header__badge-status { background: $light-100; } diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx new file mode 100644 index 0000000000..55cd19500f --- /dev/null +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -0,0 +1,155 @@ +import { useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch } from 'react-redux'; +import { useToggle } from '@edx/paragon'; + +import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; +import { RequestStatus } from '../../data/constants'; +import CardHeader from '../card-header/CardHeader'; +import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge'; +import TitleLink from '../card-header/TitleLink'; +import { getItemStatus, scrollToElement } from '../utils'; + +const UnitCard = ({ + unit, + subsection, + section, + onOpenPublishModal, + onEditSubmit, + savingStatus, + onOpenDeleteModal, + onDuplicateSubmit, + getTitleLink, +}) => { + const currentRef = useRef(null); + const dispatch = useDispatch(); + const [isFormOpen, openForm, closeForm] = useToggle(false); + const namePrefix = 'unit'; + + const { + id, + displayName, + hasChanges, + published, + releasedToStudents, + visibleToStaffOnly = false, + visibilityState, + staffOnlyMessage, + } = unit; + + const unitStatus = getItemStatus({ + published, + releasedToStudents, + visibleToStaffOnly, + visibilityState, + staffOnlyMessage, + }); + + const handleClickMenuButton = () => { + dispatch(setCurrentItem(unit)); + dispatch(setCurrentSection(section)); + dispatch(setCurrentSubsection(subsection)); + }; + + const handleEditSubmit = (titleValue) => { + if (displayName !== titleValue) { + onEditSubmit(id, section.id, titleValue); + return; + } + + closeForm(); + }; + + const titleComponent = ( + + + + ); + + useEffect(() => { + // if this items has been newly added, scroll to it. + // we need to check section.shouldScroll as whole section is fetched when a + // unit is duplicated under it. + if (currentRef.current && (section.shouldScroll || unit.shouldScroll)) { + scrollToElement(currentRef.current); + } + }, []); + + useEffect(() => { + if (savingStatus === RequestStatus.SUCCESSFUL) { + closeForm(); + } + }, [savingStatus]); + + return ( +
+ +
+ ); +}; + +UnitCard.propTypes = { + unit: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + published: PropTypes.bool.isRequired, + hasChanges: PropTypes.bool.isRequired, + releasedToStudents: PropTypes.bool.isRequired, + visibleToStaffOnly: PropTypes.bool, + visibilityState: PropTypes.string.isRequired, + staffOnlyMessage: PropTypes.bool.isRequired, + shouldScroll: PropTypes.bool, + }).isRequired, + subsection: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + published: PropTypes.bool.isRequired, + hasChanges: PropTypes.bool.isRequired, + releasedToStudents: PropTypes.bool.isRequired, + visibleToStaffOnly: PropTypes.bool, + visibilityState: PropTypes.string.isRequired, + staffOnlyMessage: PropTypes.bool.isRequired, + shouldScroll: PropTypes.bool, + }).isRequired, + section: PropTypes.shape({ + id: PropTypes.string.isRequired, + displayName: PropTypes.string.isRequired, + published: PropTypes.bool.isRequired, + hasChanges: PropTypes.bool.isRequired, + releasedToStudents: PropTypes.bool.isRequired, + visibleToStaffOnly: PropTypes.bool, + visibilityState: PropTypes.string.isRequired, + staffOnlyMessage: PropTypes.bool.isRequired, + shouldScroll: PropTypes.bool, + }).isRequired, + onOpenPublishModal: PropTypes.func.isRequired, + onEditSubmit: PropTypes.func.isRequired, + savingStatus: PropTypes.string.isRequired, + onOpenDeleteModal: PropTypes.func.isRequired, + onDuplicateSubmit: PropTypes.func.isRequired, + getTitleLink: PropTypes.func.isRequired, +}; + +export default UnitCard; diff --git a/src/course-outline/unit-card/UnitCard.scss b/src/course-outline/unit-card/UnitCard.scss new file mode 100644 index 0000000000..a1cbda28f7 --- /dev/null +++ b/src/course-outline/unit-card/UnitCard.scss @@ -0,0 +1,26 @@ +.unit-card { + @include pgn-box-shadow(1, "centered"); + + padding: $spacer 2rem; + margin-bottom: 1.5rem; + background: $light-100; + + .unit-card__content { + margin: $spacer; + } + + .item-card-header__badge-status { + background: $light-100; + } + + // used in src/course-outline/card-header/TitleLink.jsx & + // src/course-outline/card-header/TitleButton.jsx as + // `${namePrefix}-card-title` + .unit-card-title { + font-size: $h5-font-size; + font-family: $headings-font-family; + font-weight: $headings-font-weight; + line-height: $headings-line-height; + color: $headings-color; + } +} diff --git a/src/course-outline/unit-card/UnitCard.test.jsx b/src/course-outline/unit-card/UnitCard.test.jsx new file mode 100644 index 0000000000..24e717fe51 --- /dev/null +++ b/src/course-outline/unit-card/UnitCard.test.jsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { initializeMockApp } from '@edx/frontend-platform'; +import MockAdapter from 'axios-mock-adapter'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import initializeStore from '../../store'; +import UnitCard from './UnitCard'; + +// eslint-disable-next-line no-unused-vars +let axiosMock; +let store; + +const section = { + id: '1', + displayName: 'Section Name', + published: true, + releasedToStudents: true, + visibleToStaffOnly: false, + visibilityState: 'visible', + staffOnlyMessage: false, + hasChanges: false, + highlights: ['highlight 1', 'highlight 2'], +}; + +const subsection = { + id: '12', + displayName: 'Subsection Name', + published: true, + releasedToStudents: true, + visibleToStaffOnly: false, + visibilityState: 'visible', + staffOnlyMessage: false, + hasChanges: false, +}; + +const unit = { + id: '123', + displayName: 'unit Name', + published: true, + releasedToStudents: true, + visibleToStaffOnly: false, + visibilityState: 'visible', + staffOnlyMessage: false, + hasChanges: false, +}; + +const renderComponent = (props) => render( + + + `/some/${id}`} + {...props} + /> + , + , +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + + store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + it('render UnitCard component correctly', async () => { + const { findByTestId } = renderComponent(); + + expect(await findByTestId('unit-card-header')).toBeInTheDocument(); + expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123'); + }); +});