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..e01ec0cffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^1.178.0", + "@edx/frontend-lib-content-components": "^1.178.1", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^21.5.6", "@fortawesome/fontawesome-svg-core": "1.2.36", @@ -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" @@ -2763,9 +2764,9 @@ } }, "node_modules/@edx/frontend-lib-content-components": { - "version": "1.178.0", - "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.178.0.tgz", - "integrity": "sha512-i8/ACz7PVx9QEKGRQS74PQONP301D6WC+GB7Y89JnMGHNrCsWEX75zpEVUIczqabTuo/dGGVUpqu/M9lOFk4lQ==", + "version": "1.178.1", + "resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.178.1.tgz", + "integrity": "sha512-QMkC0vzPq+hqmbqchUjRBTWIyzYEnJvVKItUtBuocR7ympL7f5QKau9pHTi/5LH/HSetjhl4HnVVX/xynm2HaA==", "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-xml": "^6.0.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..215bab243a 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@edx/frontend-component-footer": "^12.3.0", "@edx/frontend-component-header": "^4.7.0", "@edx/frontend-enterprise-hotjar": "^2.0.0", - "@edx/frontend-lib-content-components": "^1.178.0", + "@edx/frontend-lib-content-components": "^1.178.1", "@edx/frontend-platform": "5.6.1", "@edx/paragon": "^21.5.6", "@fortawesome/fontawesome-svg-core": "1.2.36", @@ -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/CourseAuthoringPage.jsx b/src/CourseAuthoringPage.jsx index 3e6b50f2e3..c442f94065 100644 --- a/src/CourseAuthoringPage.jsx +++ b/src/CourseAuthoringPage.jsx @@ -54,9 +54,9 @@ const CourseAuthoringPage = ({ courseId, children }) => { const courseDetailStatus = useSelector(state => state.courseDetail.status); const inProgress = courseDetailStatus === RequestStatus.IN_PROGRESS; const { pathname } = useLocation(); - const showHeader = !pathname.includes('/editor'); + const isEditor = pathname.includes('/editor'); - if (courseDetailStatus === RequestStatus.NOT_FOUND) { + if (courseDetailStatus === RequestStatus.NOT_FOUND && !isEditor) { return ( ); @@ -72,8 +72,8 @@ const CourseAuthoringPage = ({ courseId, children }) => { using url pattern containing /editor/, we shouldn't have the header and footer on these pages. This functionality will be removed in TNL-9591 */} - {inProgress ? showHeader && - : (showHeader && ( + {inProgress ? !isEditor && + : (!isEditor && ( { ) )} {children} - {!inProgress && showHeader && } + {!inProgress && !isEditor && } ); }; 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'); + }); +}); diff --git a/src/export-page/data/thunks.js b/src/export-page/data/thunks.js index 0bfdd410bf..f5fdcae07c 100644 --- a/src/export-page/data/thunks.js +++ b/src/export-page/data/thunks.js @@ -22,6 +22,21 @@ import { updateSavingStatus, } from './slice'; +function setExportDate({ + date, exportStatus, exportOutput, dispatch, +}) { + // If there is no cookie for the last export date, set it now. + const cookies = new Cookies(); + const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); + if (!cookieData?.completed) { + setExportCookie(date, exportStatus === EXPORT_STAGES.SUCCESS); + } + // If we don't have export date set yet via cookie, set success date to current date. + if (exportOutput && !cookieData?.completed) { + dispatch(updateSuccessDate(date)); + } +} + export function startExportingCourse(courseId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); @@ -45,23 +60,16 @@ export function fetchExportStatus(courseId) { return async (dispatch) => { dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS })); try { - const { exportStatus, exportOutput, exportError } = await getExportStatus(courseId); + const { + exportStatus, exportOutput, exportError, + } = await getExportStatus(courseId); dispatch(updateCurrentStage(Math.abs(exportStatus))); - if (exportOutput) { - if (exportOutput.startsWith('/')) { - dispatch(updateDownloadPath(`${getConfig().STUDIO_BASE_URL}${exportOutput}`)); - } else { - dispatch(updateDownloadPath(exportOutput)); - } - dispatch(updateSuccessDate(moment().valueOf())); - } + const date = moment().valueOf(); - const cookies = new Cookies(); - const cookieData = cookies.get(LAST_EXPORT_COOKIE_NAME); - if (!cookieData?.completed) { - setExportCookie(moment().valueOf(), exportStatus === EXPORT_STAGES.SUCCESS); - } + setExportDate({ + date, exportStatus, exportOutput, dispatch, + }); if (exportError) { const errorMessage = exportError.rawErrorMsg || exportError; @@ -70,6 +78,14 @@ export function fetchExportStatus(courseId) { dispatch(updateIsErrorModalOpen(true)); } + if (exportOutput) { + if (exportOutput.startsWith('/')) { + dispatch(updateDownloadPath(`${getConfig().STUDIO_BASE_URL}${exportOutput}`)); + } else { + dispatch(updateDownloadPath(exportOutput)); + } + } + dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL })); return true; } catch (error) { diff --git a/src/export-page/data/thunks.test.js b/src/export-page/data/thunks.test.js new file mode 100644 index 0000000000..e8dd9762f3 --- /dev/null +++ b/src/export-page/data/thunks.test.js @@ -0,0 +1,146 @@ +import Cookies from 'universal-cookie'; +import { fetchExportStatus } from './thunks'; +import * as api from './api'; +import { EXPORT_STAGES } from './constants'; + +jest.mock('universal-cookie', () => jest.fn().mockImplementation(() => ({ + get: jest.fn().mockImplementation(() => ({ completed: false })), +}))); + +jest.mock('../utils', () => ({ + setExportCookie: jest.fn(), +})); + +describe('fetchExportStatus thunk', () => { + const dispatch = jest.fn(); + const getState = jest.fn(); + const courseId = 'course-123'; + const exportStatus = EXPORT_STAGES.COMPRESSING; + const exportOutput = 'export output'; + const exportError = 'export error'; + let mockGetExportStatus; + + beforeEach(() => { + jest.clearAllMocks(); + + mockGetExportStatus = jest.spyOn(api, 'getExportStatus').mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + }); + + it('should dispatch updateCurrentStage with export status', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: exportStatus, + type: 'exportPage/updateCurrentStage', + }); + }); + + it('should dispatch updateError on export error', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: { + msg: exportError, + unitUrl: null, + }, + type: 'exportPage/updateError', + }); + }); + + it('should dispatch updateIsErrorModalOpen with true if export error', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: true, + type: 'exportPage/updateIsErrorModalOpen', + }); + }); + + it('should not dispatch updateIsErrorModalOpen if no export error', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError: null, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).not.toHaveBeenCalledWith({ + payload: false, + type: 'exportPage/updateIsErrorModalOpen', + }); + }); + + it("should dispatch updateDownloadPath if there's export output", async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: exportOutput, + type: 'exportPage/updateDownloadPath', + }); + }); + + it('should dispatch updateSuccessDate with current date if export status is success', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus: + EXPORT_STAGES.SUCCESS, + exportOutput, + exportError, + }); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).toHaveBeenCalledWith({ + payload: expect.any(Number), + type: 'exportPage/updateSuccessDate', + }); + }); + + it('should not dispatch updateSuccessDate with current date if last-export cookie is already set', async () => { + mockGetExportStatus.mockResolvedValue({ + exportStatus: + EXPORT_STAGES.SUCCESS, + exportOutput, + exportError, + }); + + Cookies.mockImplementation(() => ({ + get: jest.fn().mockReturnValueOnce({ completed: true }), + })); + + await fetchExportStatus(courseId)(dispatch, getState); + + expect(dispatch).not.toHaveBeenCalledWith({ + payload: expect.any, + type: 'exportPage/updateSuccessDate', + }); + }); +}); diff --git a/src/files-and-videos/videos-page/data/api.js b/src/files-and-videos/videos-page/data/api.js index be38613447..58bae471fa 100644 --- a/src/files-and-videos/videos-page/data/api.js +++ b/src/files-and-videos/videos-page/data/api.js @@ -174,18 +174,21 @@ export async function uploadVideo( uploadFile, edxVideoId, ) { - const formData = new FormData(); - formData.append('uploaded-file', uploadFile); const uploadErrors = []; await fetch(uploadUrl, { method: 'PUT', - body: formData, headers: { - 'Content-Type': 'multipart/form-data', + 'Content-Disposition': `attachment; filename="${uploadFile.name}"`, + 'Content-Type': uploadFile.type, }, + multipart: false, + body: uploadFile, }) - .then(async () => { + .then(async (response) => { + if (!response.ok) { + throw new Error(); + } await getAuthenticatedHttpClient() .post(getCourseVideosApiUrl(courseId), [{ edxVideoId, diff --git a/src/files-and-videos/videos-page/data/utils.js b/src/files-and-videos/videos-page/data/utils.js index a19c8fe1b6..1e5e7a5dc7 100644 --- a/src/files-and-videos/videos-page/data/utils.js +++ b/src/files-and-videos/videos-page/data/utils.js @@ -110,11 +110,19 @@ export const getSupportedFormats = (supportedFileFormats) => { let format; if (isArray(value)) { value.forEach(val => { - format = key.replace('*', val.substring(1)); + if (val === '.mov') { + format = key.replace('*', 'quicktime'); + } else { + format = key.replace('*', val.substring(1)); + } supportedFormats.push(format); }); } else { - format = key.replace('*', value?.substring(1)); + if (value === '.mov') { + format = key.replace('*', 'quicktime'); + } else { + format = key.replace('*', value.substring(1)); + } supportedFormats.push(format); } }); diff --git a/src/files-and-videos/videos-page/data/utils.test.js b/src/files-and-videos/videos-page/data/utils.test.js index 2d3708a99d..54ca8dee0f 100644 --- a/src/files-and-videos/videos-page/data/utils.test.js +++ b/src/files-and-videos/videos-page/data/utils.test.js @@ -23,8 +23,13 @@ describe('getSupportedFormats', () => { const actual = getSupportedFormats({ 'image/*': '.png' }); expect(expected).toEqual(actual); }); + it('should return video/quicktime for .mov', () => { + const expected = ['video/quicktime']; + const actual = getSupportedFormats({ 'video/*': '.mov' }); + expect(expected).toEqual(actual); + }); it('should return array of valid file types', () => { - const expected = ['video/mp4', 'video/mov']; + const expected = ['video/mp4', 'video/quicktime']; const actual = getSupportedFormats({ 'video/*': ['.mp4', '.mov'] }); expect(expected).toEqual(actual); }); diff --git a/src/index.jsx b/src/index.jsx index 7ef042750d..3488cca8cd 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -2,7 +2,7 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; import { - APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, + APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig, getPath, } from '@edx/frontend-platform'; import { AppProvider, ErrorPage } from '@edx/frontend-platform/react'; import React, { useEffect } from 'react'; @@ -71,6 +71,9 @@ const App = () => { )} , ), + { + basename: getPath(getConfig().PUBLIC_PATH), + }, ); return ( diff --git a/src/taxonomy/TaxonomyListPage.jsx b/src/taxonomy/TaxonomyListPage.jsx index d0c6d9a408..01d81a5294 100644 --- a/src/taxonomy/TaxonomyListPage.jsx +++ b/src/taxonomy/TaxonomyListPage.jsx @@ -17,6 +17,7 @@ import { Check, } from '@edx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; + import { Helmet } from 'react-helmet'; import { useOrganizationListData } from '../generic/data/apiHooks'; diff --git a/src/taxonomy/TaxonomyListPage.test.jsx b/src/taxonomy/TaxonomyListPage.test.jsx index 978f1731ac..b9aa42c8c8 100644 --- a/src/taxonomy/TaxonomyListPage.test.jsx +++ b/src/taxonomy/TaxonomyListPage.test.jsx @@ -51,9 +51,7 @@ const RootWrapper = () => ( - - - + @@ -75,6 +73,10 @@ describe('', () => { axiosMock.onGet(organizationsListUrl).reply(200, organizations); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should render page and page title correctly', () => { const { getByText } = render(); expect(getByText('Taxonomies')).toBeInTheDocument(); @@ -143,7 +145,11 @@ describe('', () => { it('should show all "All taxonomies", "Unassigned" and org names in taxonomy org filter', async () => { useIsTaxonomyListDataLoaded.mockReturnValue(true); useTaxonomyListDataResponse.mockReturnValue({ - results: taxonomies, + results: [{ + id: 1, + name: 'Taxonomy', + description: 'This is a description', + }], }); const { diff --git a/src/taxonomy/data/api.js b/src/taxonomy/data/api.js index 46f5c44494..c4a3a7d653 100644 --- a/src/taxonomy/data/api.js +++ b/src/taxonomy/data/api.js @@ -21,10 +21,17 @@ export const getExportTaxonomyApiUrl = (pk, format) => new URL( `api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`, getApiBaseUrl(), ).href; + export const getTaxonomyTemplateApiUrl = (format) => new URL( `api/content_tagging/v1/taxonomies/import/template.${format}`, getApiBaseUrl(), ).href; + +/** + * Get the URL for a Taxonomy + * @param {number} pk + * @returns {string} + */ export const getTaxonomyApiUrl = (pk) => new URL(`api/content_tagging/v1/taxonomies/${pk}/`, getApiBaseUrl()).href; /** @@ -46,6 +53,15 @@ export async function deleteTaxonomy(pk) { await getAuthenticatedHttpClient().delete(getTaxonomyApiUrl(pk)); } +/** Get a Taxonomy + * @param {number} pk + * @returns {Promise} + */ +export async function getTaxonomy(pk) { + const { data } = await getAuthenticatedHttpClient().get(getTaxonomyApiUrl(pk)); + return camelCaseObject(data); +} + /** * Downloads the file of the exported taxonomy * @param {number} pk diff --git a/src/taxonomy/data/api.test.js b/src/taxonomy/data/api.test.js index dc277db8d7..50ddfe55f7 100644 --- a/src/taxonomy/data/api.test.js +++ b/src/taxonomy/data/api.test.js @@ -10,6 +10,7 @@ import { getTaxonomyListApiUrl, getTaxonomyListData, getTaxonomyApiUrl, + getTaxonomy, deleteTaxonomy, } from './api'; @@ -65,6 +66,13 @@ describe('taxonomy api calls', () => { expect(axiosMock.history.delete[0].url).toEqual(getTaxonomyApiUrl()); }); + it('should call get taxonomy', async () => { + axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200); + await getTaxonomy(1); + + expect(axiosMock.history.get[0].url).toEqual(getTaxonomyApiUrl(1)); + }); + it('Export should set window.location.href correctly', () => { const pk = 1; const format = 'json'; diff --git a/src/taxonomy/data/apiHooks.jsx b/src/taxonomy/data/apiHooks.jsx index eea6d5d9f4..febacf43bd 100644 --- a/src/taxonomy/data/apiHooks.jsx +++ b/src/taxonomy/data/apiHooks.jsx @@ -12,7 +12,7 @@ * Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded. */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getTaxonomyListData, deleteTaxonomy } from './api'; +import { getTaxonomyListData, deleteTaxonomy, getTaxonomy } from './api'; /** * Builds the query to get the taxonomy list @@ -41,6 +41,16 @@ export const useDeleteTaxonomy = () => { return mutate; }; +/** Builds the query to get the taxonomy detail + * @param {number} taxonomyId + */ +const useTaxonomyDetailData = (taxonomyId) => ( + useQuery({ + queryKey: ['taxonomyDetail', taxonomyId], + queryFn: async () => getTaxonomy(taxonomyId), + }) +); + /** * Gets the taxonomy list data * @param {string} org Optional organization query param @@ -62,3 +72,35 @@ export const useTaxonomyListDataResponse = (org) => { export const useIsTaxonomyListDataLoaded = (org) => ( useTaxonomyListData(org).status === 'success' ); + +/** + * @param {number} taxonomyId + * @returns {Pick} + */ +export const useTaxonomyDetailDataStatus = (taxonomyId) => { + const { + isError, + error, + isFetched, + isSuccess, + } = useTaxonomyDetailData(taxonomyId); + return { + isError, + error, + isFetched, + isSuccess, + }; +}; + +/** + * @param {number} taxonomyId + * @returns {import("./types.mjs").TaxonomyData | undefined} + */ +export const useTaxonomyDetailDataResponse = (taxonomyId) => { + const { isSuccess, data } = useTaxonomyDetailData(taxonomyId); + if (isSuccess) { + return data; + } + + return undefined; +}; diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index e0160ad8d7..b5ec263049 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -1,5 +1,6 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { act } from '@testing-library/react'; + import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, diff --git a/src/taxonomy/data/types.mjs b/src/taxonomy/data/types.mjs index 82f1fb427a..6268b882e0 100644 --- a/src/taxonomy/data/types.mjs +++ b/src/taxonomy/data/types.mjs @@ -12,6 +12,7 @@ * @property {boolean} visibleToAuthors * @property {number} tagsCount * @property {string[]} orgs + * @property {boolean} allOrgs * @property {boolean} canChange * @property {boolean} canDelete */ diff --git a/src/taxonomy/import-tags/data/utils.test.js b/src/taxonomy/import-tags/data/utils.test.js new file mode 100644 index 0000000000..ddcc029410 --- /dev/null +++ b/src/taxonomy/import-tags/data/utils.test.js @@ -0,0 +1,301 @@ +import { importTaxonomy, importTaxonomyTags } from './utils'; +import { importNewTaxonomy, importTags } from './api'; + +const mockAddEventListener = jest.fn(); + +const intl = { + formatMessage: jest.fn().mockImplementation((message) => message.defaultMessage), +}; + +jest.mock('./api', () => ({ + importNewTaxonomy: jest.fn().mockResolvedValue({}), + importTags: jest.fn().mockResolvedValue({}), +})); + +describe('import new taxonomy functions', () => { + let createElement; + let appendChild; + let removeChild; + + beforeEach(() => { + createElement = document.createElement; + document.createElement = jest.fn().mockImplementation((element) => { + if (element === 'input') { + return { + click: jest.fn(), + addEventListener: mockAddEventListener, + style: {}, + }; + } + return createElement(element); + }); + + appendChild = document.body.appendChild; + document.body.appendChild = jest.fn(); + + removeChild = document.body.removeChild; + document.body.removeChild = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + document.createElement = createElement; + document.body.appendChild = appendChild; + document.body.removeChild = removeChild; + }); + + describe('import new taxonomy', () => { + it('should call the api and show success alert', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should ask for taxonomy name again if not provided', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('You must enter a name for the new taxonomy'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should call the api and return error alert', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce('test taxonomy description'); + importNewTaxonomy.mockRejectedValue(new Error('test error')); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).toHaveBeenCalledWith('test taxonomy name', 'test taxonomy description', 'mockFile'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api without file', async () => { + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [null], + }, + }; + + onChange(mockTarget); + return promise; + }); + + it('should abort the call to the api if file closed', async () => { + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onCancel handler from the file input element + const onCancel = mockAddEventListener.mock.calls[1][1]; + + onCancel(); + return promise; + }); + + it('should abort the call to the api when cancel name prompt', async () => { + jest.spyOn(window, 'prompt').mockReturnValueOnce(null); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api when cancel description prompt', async () => { + jest.spyOn(window, 'prompt') + .mockReturnValueOnce('test taxonomy name') + .mockReturnValueOnce(null); + + const promise = importTaxonomy(intl).then(() => { + expect(importNewTaxonomy).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + return promise; + }); + }); + + describe('import tags', () => { + it('should call the api and show success alert', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(true); + jest.spyOn(window, 'alert').mockImplementation(() => {}); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); + expect(window.alert).toHaveBeenCalledWith('Taxonomy imported successfully'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should abort the call to the api without file', async () => { + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [null], + }, + }; + + onChange(mockTarget); + return promise; + }); + + it('should abort the call to the api if file closed', async () => { + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onCancel handler from the file input element + const onCancel = mockAddEventListener.mock.calls[1][1]; + + onCancel(); + return promise; + }); + + it('should abort the call to the api when cancel the confirm dialog', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(null); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).not.toHaveBeenCalled(); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + + it('should call the api and return error alert', async () => { + jest.spyOn(window, 'confirm').mockReturnValueOnce(true); + importTags.mockRejectedValue(new Error('test error')); + + const promise = importTaxonomyTags(1, intl).then(() => { + expect(importTags).toHaveBeenCalledWith(1, 'mockFile'); + }); + + // Capture the onChange handler from the file input element + const onChange = mockAddEventListener.mock.calls[0][1]; + const mockTarget = { + target: { + files: [ + 'mockFile', + ], + }, + }; + + onChange(mockTarget); + + return promise; + }); + }); +}); diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx new file mode 100644 index 0000000000..faa799b01a --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.jsx @@ -0,0 +1,239 @@ +// @ts-check +import React, { useContext, useEffect, useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + useToggle, + ActionRow, + AlertModal, + Button, + Chip, + Container, + Form, + ModalDialog, + Stack, +} from '@edx/paragon'; +import { + Close, + Warning, +} from '@edx/paragon/icons'; +import PropTypes from 'prop-types'; + +import { useOrganizationListData } from '../../generic/data/apiHooks'; +import { TaxonomyContext } from '../common/context'; +import { useTaxonomyDetailDataResponse } from '../data/apiHooks'; +import { useManageOrgs } from './data/api'; +import messages from './messages'; +import './ManageOrgsModal.scss'; + +const ConfirmModal = ({ + isOpen, + onClose, + confirm, + taxonomyName, +}) => { + const intl = useIntl(); + return ( + + + + + )} + > +

+ {intl.formatMessage(messages.confirmUnassignText, { taxonomyName })} +

+
+ ); +}; + +ConfirmModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + confirm: PropTypes.func.isRequired, + taxonomyName: PropTypes.string.isRequired, +}; + +const ManageOrgsModal = ({ + taxonomyId, + isOpen, + onClose, +}) => { + const intl = useIntl(); + const { setToastMessage } = useContext(TaxonomyContext); + + const [selectedOrgs, setSelectedOrgs] = useState(/** @type {null|string[]} */(null)); + const [allOrgs, setAllOrgs] = useState(/** @type {null|boolean} */(null)); + + const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + + const [isDialogDisabled, disableDialog, enableDialog] = useToggle(false); + + const { + data: organizationListData, + } = useOrganizationListData(); + + const taxonomy = useTaxonomyDetailDataResponse(taxonomyId); + + const manageOrgMutation = useManageOrgs(); + + const saveOrgs = async () => { + disableDialog(); + closeConfirmModal(); + if (selectedOrgs !== null && allOrgs !== null) { + try { + await manageOrgMutation.mutateAsync({ + taxonomyId, + orgs: allOrgs ? undefined : selectedOrgs, + allOrgs, + }); + if (setToastMessage) { + setToastMessage(intl.formatMessage(messages.assignOrgsSuccess)); + } + } catch (error) { + // ToDo: display the error to the user + } finally { + enableDialog(); + onClose(); + } + } + }; + + const confirmSave = async () => { + if (!selectedOrgs?.length && !allOrgs) { + openConfirmModal(); + } else { + await saveOrgs(); + } + }; + + useEffect(() => { + if (taxonomy) { + if (selectedOrgs === null) { + setSelectedOrgs([...taxonomy.orgs]); + } + if (allOrgs === null) { + setAllOrgs(taxonomy.allOrgs); + } + } + }, [taxonomy]); + + useEffect(() => { + if (selectedOrgs) { + // This is a hack to force the Form.Autosuggest to clear its value after a selection is made. + const inputRef = /** @type {null|HTMLInputElement} */ (document.querySelector('.manage-orgs .pgn__form-group input')); + if (inputRef) { + // @ts-ignore value can be null + inputRef.value = null; + const event = new Event('change', { bubbles: true }); + inputRef.dispatchEvent(event); + } + } + }, [selectedOrgs]); + + if (!selectedOrgs || !taxonomy) { + return null; + } + + return ( + e.stopPropagation() /* This prevents calling onClick handler from the parent */}> + + {isDialogDisabled && ( + // This div is used to prevent the user from interacting with the dialog while it is disabled +
+ )} + + + + {intl.formatMessage(messages.headerTitle)} + + + +
+ + + + +
{intl.formatMessage(messages.bodyText)}
+
{intl.formatMessage(messages.currentAssignments)}
+
+ {selectedOrgs.length ? selectedOrgs.map((org) => ( + setSelectedOrgs(selectedOrgs.filter((o) => o !== org))} + disabled={allOrgs} + > + {org} + + )) : {intl.formatMessage(messages.noOrganizationAssigned)} } +
+
+
+ + + {intl.formatMessage(messages.addOrganizations)} + + setSelectedOrgs([...selectedOrgs, org])} + disabled={allOrgs} + > + {organizationListData ? organizationListData.filter(o => !selectedOrgs?.includes(o)).map((org) => ( + {org} + )) : [] } + + + setAllOrgs(e.target.checked)}> + {intl.formatMessage(messages.assignAll)} + +
+ +
+ + + + + {intl.formatMessage(messages.cancelButton)} + + + + + + + + ); +}; + +ManageOrgsModal.propTypes = { + taxonomyId: PropTypes.number.isRequired, + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, +}; + +export default ManageOrgsModal; diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.scss b/src/taxonomy/manage-orgs/ManageOrgsModal.scss new file mode 100644 index 0000000000..4ae418ece0 --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.scss @@ -0,0 +1,13 @@ +.manage-orgs { + /* + This style is needed to override the default overflow: scroll on the modal, + preventing the dropdown to overflow the modal. + This is being fixed here: + https://github.com/openedx/paragon/pull/2939 + */ + overflow: visible !important; + + .pgn__modal-body { + overflow: visible; + } +} diff --git a/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx new file mode 100644 index 0000000000..01f7a7bf77 --- /dev/null +++ b/src/taxonomy/manage-orgs/ManageOrgsModal.test.jsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + fireEvent, + render, + waitFor, +} from '@testing-library/react'; +import PropTypes from 'prop-types'; + +import initializeStore from '../../store'; +import { TaxonomyContext } from '../common/context'; +import ManageOrgsModal from './ManageOrgsModal'; + +let store; + +const taxonomy = { + id: 1, + name: 'Test Taxonomy', + allOrgs: false, + orgs: ['org1', 'org2'], +}; + +const orgs = ['org1', 'org2', 'org3', 'org4', 'org5']; + +jest.mock('../data/api', () => ({ + ...jest.requireActual('../data/api'), + getTaxonomy: jest.fn().mockResolvedValue(taxonomy), +})); + +jest.mock('../../generic/data/api', () => ({ + ...jest.requireActual('../../generic/data/api'), + getOrganizations: jest.fn().mockResolvedValue(orgs), +})); + +const mockUseManageOrgsMutate = jest.fn(); + +jest.mock('./data/api', () => ({ + ...jest.requireActual('./data/api'), + useManageOrgs: jest.fn(() => ({ + ...jest.requireActual('./data/api').useManageOrgs(), + mutateAsync: mockUseManageOrgsMutate, + })), +})); + +const mockSetToastMessage = jest.fn(); +const mockSetAlertProps = jest.fn(); +const context = { + toastMessage: null, + setToastMessage: mockSetToastMessage, + alertProps: null, + setAlertProps: mockSetAlertProps, +}; + +const queryClient = new QueryClient(); + +const RootWrapper = ({ onClose }) => ( + + + + + + + + + +); + +RootWrapper.propTypes = { + onClose: PropTypes.func.isRequired, +}; + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + queryClient.clear(); + }); + + const checkDialogRender = async (getByText) => { + await waitFor(() => { + // Dialog title + expect(getByText('Assign to organizations')).toBeInTheDocument(); + // Orgs assigned to the taxonomy + expect(getByText('org1')).toBeInTheDocument(); + expect(getByText('org2')).toBeInTheDocument(); + }); + }; + + it('should render the dialog and close on cancel', async () => { + const onClose = jest.fn(); + const { getByText, getByRole } = render(); + + await checkDialogRender(getByText); + + const cancelButton = getByRole('button', { name: 'Cancel' }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it('can assign orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { + queryAllByTestId, + getByTestId, + getByText, + } = render(); + + await checkDialogRender(getByText); + + // Remove org2 + fireEvent.click(getByText('org2').nextSibling); + + const input = getByTestId('autosuggest-iconbutton'); + fireEvent.click(input); + + const list = queryAllByTestId('autosuggest-optionitem'); + expect(list.length).toBe(4); // Show org3, org4, org5 + expect(getByText('org2')).toBeInTheDocument(); + expect(getByText('org3')).toBeInTheDocument(); + expect(getByText('org4')).toBeInTheDocument(); + expect(getByText('org5')).toBeInTheDocument(); + + // Select org3 + fireEvent.click(list[1]); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + orgs: ['org1', 'org3'], + allOrgs: false, + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); + + it('can assign all orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { getByRole, getByTestId, getByText } = render(); + + await checkDialogRender(getByText); + + const checkbox = getByRole('checkbox', { name: 'Assign to all organizations' }); + fireEvent.click(checkbox); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + allOrgs: true, + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); + + it('can assign no orgs to taxonomies from the dialog', async () => { + const onClose = jest.fn(); + const { getByRole, getByTestId, getByText } = render(); + + await checkDialogRender(getByText); + + // Remove org1 + fireEvent.click(getByText('org1').nextSibling); + // Remove org2 + fireEvent.click(getByText('org2').nextSibling); + + fireEvent.click(getByTestId('save-button')); + + await waitFor(() => { + // Check confirm modal is open + expect(getByText('Unassign taxonomy')).toBeInTheDocument(); + }); + + fireEvent.click(getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(mockUseManageOrgsMutate).toHaveBeenCalledWith({ + taxonomyId: taxonomy.id, + allOrgs: false, + orgs: [], + }); + }); + + // Toast message shown + expect(mockSetToastMessage).toBeCalledWith('Assigned organizations updated'); + }); +}); diff --git a/src/taxonomy/manage-orgs/data/api.js b/src/taxonomy/manage-orgs/data/api.js new file mode 100644 index 0000000000..499aa3c731 --- /dev/null +++ b/src/taxonomy/manage-orgs/data/api.js @@ -0,0 +1,54 @@ +// @ts-check +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + +/** + * @param {number} taxonomyId + * @returns {string} + */ +export const getManageOrgsApiUrl = (taxonomyId) => new URL( + `api/content_tagging/v1/taxonomies/${taxonomyId}/orgs/`, + getApiBaseUrl(), +).href; + +/** + * Build the mutation to assign organizations to a taxonomy. + */ +export const useManageOrgs = () => { + const queryClient = useQueryClient(); + return useMutation({ + /** + * @type {import("@tanstack/react-query").MutateFunction< + * any, + * any, + * { + * taxonomyId: number, + * orgs?: string[], + * allOrgs: boolean, + * } + * >} + */ + mutationFn: async ({ taxonomyId, orgs, allOrgs }) => { + const { data } = await getAuthenticatedHttpClient().put( + getManageOrgsApiUrl(taxonomyId), + { + all_orgs: allOrgs, + orgs: allOrgs ? undefined : orgs, + }, + ); + + return camelCaseObject(data); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['taxonomyList'], + }); + queryClient.invalidateQueries({ + queryKey: ['taxonomyDetail', variables.taxonomyId], + }); + }, + }); +}; diff --git a/src/taxonomy/manage-orgs/data/api.test.jsx b/src/taxonomy/manage-orgs/data/api.test.jsx new file mode 100644 index 0000000000..40f047f6e6 --- /dev/null +++ b/src/taxonomy/manage-orgs/data/api.test.jsx @@ -0,0 +1,81 @@ +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { renderHook } from '@testing-library/react-hooks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import MockAdapter from 'axios-mock-adapter'; + +import { + getManageOrgsApiUrl, + useManageOrgs, + +} from './api'; + +let axiosMock; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const wrapper = ({ children }) => ( + + {children} + +); + +describe('import taxonomy api calls', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call update taxonomy orgs', async () => { + axiosMock.onPut(getManageOrgsApiUrl(1)).reply(200); + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useManageOrgs(), { wrapper }); + + await result.current.mutateAsync({ taxonomyId: 1, orgs: ['org1', 'org2'], allOrgs: false }); + expect(axiosMock.history.put[0].url).toEqual(getManageOrgsApiUrl(1)); + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ all_orgs: false, orgs: ['org1', 'org2'] })); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyList'], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyDetail', 1], + }); + }); + + it('should call update taxonomy orgs with allOrgs', async () => { + axiosMock.onPut(getManageOrgsApiUrl(1)).reply(200); + const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useManageOrgs(), { wrapper }); + + await result.current.mutateAsync({ taxonomyId: 1, orgs: ['org1', 'org2'], allOrgs: true }); + expect(axiosMock.history.put[0].url).toEqual(getManageOrgsApiUrl(1)); + // Should not send orgs when allOrgs is true + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify({ all_orgs: true })); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyList'], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: ['taxonomyDetail', 1], + }); + }); +}); diff --git a/src/taxonomy/manage-orgs/index.js b/src/taxonomy/manage-orgs/index.js new file mode 100644 index 0000000000..9006d2be41 --- /dev/null +++ b/src/taxonomy/manage-orgs/index.js @@ -0,0 +1 @@ +export { default as ManageOrgsModal } from './ManageOrgsModal'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/manage-orgs/messages.js b/src/taxonomy/manage-orgs/messages.js new file mode 100644 index 0000000000..3bfeafe585 --- /dev/null +++ b/src/taxonomy/manage-orgs/messages.js @@ -0,0 +1,65 @@ +// @ts-check +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + headerTitle: { + id: 'course-authoring.taxonomy-manage-orgs.header.title', + defaultMessage: 'Assign to organizations', + }, + bodyText: { + id: 'course-authoring.taxonomy-manage-orgs.body.text', + defaultMessage: 'Manage which organizations can access the taxonomy by assigning them in the menu below. You can ' + + 'also choose to assign the taxonomy to all organizations.', + }, + assignOrgs: { + id: 'course-authoring.taxonomy-manage-orgs.assign-orgs', + defaultMessage: 'Assign organizations', + }, + currentAssignments: { + id: 'course-authoring.taxonomy-manage-orgs.current-assignments', + defaultMessage: 'Currently assigned:', + }, + addOrganizations: { + id: 'course-authoring.taxonomy-manage-orgs.add-orgs', + defaultMessage: 'Add another organization:', + }, + searchOrganizations: { + id: 'course-authoring.taxonomy-manage-orgs.search-orgs', + defaultMessage: 'Search for an organization', + }, + noOrganizationAssigned: { + id: 'course-authoring.taxonomy-manage-orgs.no-orgs', + defaultMessage: 'No organizations assigned', + }, + assignAll: { + id: 'course-authoring.taxonomy-manage-orgs.assign-all', + defaultMessage: 'Assign to all organizations', + }, + cancelButton: { + id: 'course-authoring.taxonomy-manage-orgs.button.cancel', + defaultMessage: 'Cancel', + }, + saveButton: { + id: 'course-authoring.taxonomy-manage-orgs.button.save', + defaultMessage: 'Save', + }, + confirmUnassignTitle: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.title', + defaultMessage: 'Unassign taxonomy', + }, + confirmUnassignText: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.text', + defaultMessage: 'Content authors from unassigned organizations will not be able to tag course content with ' + + '{taxonomyName}. Are you sure you want to continue?', + }, + continueButton: { + id: 'course-authoring.taxonomy-manage-orgs.confirm-dialog.button.continue', + defaultMessage: 'Continue', + }, + assignOrgsSuccess: { + id: 'course-authoring.taxonomy-manage-orgs.toast.assign-orgs-success', + defaultMessage: 'Assigned organizations updated', + }, +}); + +export default messages; diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx index 9efd86e42c..486d7ac165 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.jsx @@ -17,7 +17,7 @@ import taxonomyMessages from '../messages'; import { TagListTable } from '../tag-list'; import { TaxonomyMenu } from '../taxonomy-menu'; import TaxonomyDetailSideCard from './TaxonomyDetailSideCard'; -import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks'; +import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from '../data/apiHooks'; import SystemDefinedBadge from '../system-defined-badge'; const TaxonomyDetailPage = () => { diff --git a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx index fc57a9f601..30bc5324c4 100644 --- a/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx +++ b/src/taxonomy/taxonomy-detail/TaxonomyDetailPage.test.jsx @@ -1,22 +1,20 @@ -import React, { useMemo } from 'react'; +import MockAdapter from 'axios-mock-adapter'; import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { fireEvent, render } from '@testing-library/react'; -import { useTaxonomyDetailData } from './data/api'; +import { getTaxonomyApiUrl } from '../data/api'; import initializeStore from '../../store'; import TaxonomyDetailPage from './TaxonomyDetailPage'; -import { TaxonomyContext } from '../common/context'; let store; const mockNavigate = jest.fn(); const mockMutate = jest.fn(); -const mockSetToastMessage = jest.fn(); +let axiosMock; -jest.mock('./data/api', () => ({ - useTaxonomyDetailData: jest.fn(), -})); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts useParams: () => ({ @@ -25,31 +23,27 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); jest.mock('../data/apiHooks', () => ({ + ...jest.requireActual('../data/apiHooks'), useDeleteTaxonomy: () => mockMutate, })); jest.mock('./TaxonomyDetailSideCard', () => jest.fn(() => <>Mock TaxonomyDetailSideCard)); jest.mock('../tag-list/TagListTable', () => jest.fn(() => <>Mock TagListTable)); -const RootWrapper = () => { - const context = useMemo(() => ({ - toastMessage: null, - setToastMessage: mockSetToastMessage, - }), []); - - return ( - - - - - - - - ); -}; - -describe('', async () => { - beforeEach(async () => { +const queryClient = new QueryClient(); + +const RootWrapper = () => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { initializeMockApp({ authenticatedUser: { userId: 3, @@ -59,41 +53,46 @@ describe('', async () => { }, }); store = initializeStore(); + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); }); - it('shows the spinner before the query is complete', async () => { - useTaxonomyDetailData.mockReturnValue({ - isFetched: false, - }); + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + queryClient.clear(); + }); + + it('shows the spinner before the query is complete', () => { + // Use unresolved promise to keep the Loading visible + axiosMock.onGet(getTaxonomyApiUrl(1)).reply(() => new Promise()); const { getByRole } = render(); const spinner = getByRole('status'); expect(spinner.textContent).toEqual('Loading...'); }); - it('shows the connector error component if got some error', async () => { - useTaxonomyDetailData.mockReturnValue({ - isFetched: true, - isError: true, - }); - const { getByTestId } = render(); - expect(getByTestId('connectionErrorAlert')).toBeInTheDocument(); + it('shows the connector error component if not taxonomy returned', async () => { + // Use empty response to trigger the error. Returning an error do not + // work because the query will retry. + axiosMock.onGet(getTaxonomyApiUrl(1)).reply(200); + + const { findByTestId } = render(); + + expect(await findByTestId('connectionErrorAlert')).toBeInTheDocument(); }); it('should render page and page title correctly', async () => { - useTaxonomyDetailData.mockReturnValue({ - isSuccess: true, - isFetched: true, - isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: true, - canChange: true, - canDelete: true, - }, + await axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + system_defined: false, + canChange: true, + canDelete: true, }); - const { getByTestId, queryByTestId } = render(); + + const { getByTestId, queryByTestId, findByRole } = render(); + + expect(await findByRole('heading')).toHaveTextContent('Test taxonomy'); // Menu closed/doesn't exist yet expect(queryByTestId('taxonomy-menu')).not.toBeInTheDocument(); @@ -120,35 +119,30 @@ describe('', async () => { }); it('should show system defined badge', async () => { - useTaxonomyDetailData.mockReturnValue({ - isSuccess: true, - isFetched: true, - isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: true, - userPermissions: {}, - }, + axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + system_defined: true, }); - const { getByText } = render(); + + const { findByRole, getByText } = render(); + + expect(await findByRole('heading')).toHaveTextContent('Test taxonomy'); expect(getByText('System-level')).toBeInTheDocument(); }); it('should not show system defined badge', async () => { - useTaxonomyDetailData.mockReturnValue({ - isSuccess: true, - isFetched: true, - isError: false, - data: { - id: 1, - name: 'Test taxonomy', - description: 'This is a description', - systemDefined: false, - }, + axiosMock.onGet(getTaxonomyApiUrl(1)).replyOnce(200, { + id: 1, + name: 'Test taxonomy', + description: 'This is a description', + system_defined: false, }); - const { queryByText } = render(); + + const { findByRole, queryByText } = render(); + + expect(await findByRole('heading')).toHaveTextContent('Test taxonomy'); expect(queryByText('System-level')).not.toBeInTheDocument(); }); }); diff --git a/src/taxonomy/taxonomy-detail/data/api.js b/src/taxonomy/taxonomy-detail/data/api.js deleted file mode 100644 index 3cc9126059..0000000000 --- a/src/taxonomy/taxonomy-detail/data/api.js +++ /dev/null @@ -1,28 +0,0 @@ -// @ts-check - -// TODO: this file needs to be merged into src/taxonomy/data/api.js -// We are creating a mess with so many different /data/[api|types].js files in subfolders. -// There is only one tagging/taxonomy API, and it should be implemented via a single types.mjs and api.js file. - -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { useQuery } from '@tanstack/react-query'; - -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; -const getTaxonomyDetailApiUrl = (taxonomyId) => new URL( - `api/content_tagging/v1/taxonomies/${taxonomyId}/`, - getApiBaseUrl(), -).href; - -/** - * @param {number} taxonomyId - * @returns {import('@tanstack/react-query').UseQueryResult} - */ // eslint-disable-next-line import/prefer-default-export -export const useTaxonomyDetailData = (taxonomyId) => ( - useQuery({ - queryKey: ['taxonomyDetail', taxonomyId], - queryFn: () => getAuthenticatedHttpClient().get(getTaxonomyDetailApiUrl(taxonomyId)) - .then((response) => response.data) - .then(camelCaseObject), - }) -); diff --git a/src/taxonomy/taxonomy-detail/data/api.test.js b/src/taxonomy/taxonomy-detail/data/api.test.js deleted file mode 100644 index 257421680c..0000000000 --- a/src/taxonomy/taxonomy-detail/data/api.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { - useTaxonomyDetailData, -} from './api'; - -const mockHttpClient = { - get: jest.fn(), -}; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -jest.mock('@edx/frontend-platform/auth', () => ({ - getAuthenticatedHttpClient: jest.fn(() => mockHttpClient), -})); - -describe('useTaxonomyDetailData', () => { - it('should call useQuery with the correct parameters', () => { - useTaxonomyDetailData('1'); - - expect(useQuery).toHaveBeenCalledWith({ - queryKey: ['taxonomyDetail', '1'], - queryFn: expect.any(Function), - }); - }); -}); diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.jsx deleted file mode 100644 index 7a06b33618..0000000000 --- a/src/taxonomy/taxonomy-detail/data/apiHooks.jsx +++ /dev/null @@ -1,36 +0,0 @@ -// @ts-check -import { - useTaxonomyDetailData, -} from './api'; - -/** - * @param {number} taxonomyId - * @returns {Pick} - */ -export const useTaxonomyDetailDataStatus = (taxonomyId) => { - const { - isError, - error, - isFetched, - isSuccess, - } = useTaxonomyDetailData(taxonomyId); - return { - isError, - error, - isFetched, - isSuccess, - }; -}; - -/** - * @param {number} taxonomyId - * @returns {import("../../data/types.mjs").TaxonomyData | undefined} - */ -export const useTaxonomyDetailDataResponse = (taxonomyId) => { - const { isSuccess, data } = useTaxonomyDetailData(taxonomyId); - if (isSuccess) { - return data; - } - - return undefined; -}; diff --git a/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx b/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx deleted file mode 100644 index e69232c363..0000000000 --- a/src/taxonomy/taxonomy-detail/data/apiHooks.test.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { - useTaxonomyDetailDataStatus, - useTaxonomyDetailDataResponse, -} from './apiHooks'; - -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(), -})); - -describe('useTaxonomyDetailDataStatus', () => { - it('should return status values', () => { - const status = { - isError: false, - error: undefined, - isFetched: true, - isSuccess: true, - }; - - useQuery.mockReturnValueOnce(status); - - const result = useTaxonomyDetailDataStatus(0); - - expect(result).toEqual(status); - }); -}); - -describe('useTaxonomyDetailDataResponse', () => { - it('should return data when status is success', () => { - useQuery.mockReturnValueOnce({ isSuccess: true, data: 'data' }); - - const result = useTaxonomyDetailDataResponse(); - - expect(result).toEqual('data'); - }); - - it('should return undefined when status is not success', () => { - useQuery.mockReturnValueOnce({ isSuccess: false }); - - const result = useTaxonomyDetailDataResponse(); - - expect(result).toBeUndefined(); - }); -}); diff --git a/src/taxonomy/taxonomy-detail/index.js b/src/taxonomy/taxonomy-detail/index.js index 5665033c97..8d14e0d50d 100644 --- a/src/taxonomy/taxonomy-detail/index.js +++ b/src/taxonomy/taxonomy-detail/index.js @@ -1,2 +1,2 @@ -// ts-check +// @ts-check export { default as TaxonomyDetailPage } from './TaxonomyDetailPage'; // eslint-disable-line import/prefer-default-export diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx index 12fbe0dc39..6033b618de 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.jsx @@ -18,6 +18,7 @@ import { useDeleteTaxonomy } from '../data/apiHooks'; import { TaxonomyContext } from '../common/context'; import DeleteDialog from '../delete-dialog'; import { importTaxonomyTags } from '../import-tags'; +import { ManageOrgsModal } from '../manage-orgs'; import messages from './messages'; const TaxonomyMenu = ({ @@ -45,6 +46,7 @@ const TaxonomyMenu = ({ const [isDeleteDialogOpen, deleteDialogOpen, deleteDialogClose] = useToggle(false); const [isExportModalOpen, exportModalOpen, exportModalClose] = useToggle(false); + const [isManageOrgsModalOpen, manageOrgsModalOpen, manageOrgsModalClose] = useToggle(false); /** * @typedef {Object} MenuItem @@ -71,6 +73,11 @@ const TaxonomyMenu = ({ action: deleteDialogOpen, show: taxonomy.canDelete, }, + manageOrgs: { + title: intl.formatMessage(messages.manageOrgsMenu), + action: manageOrgsModalOpen, + show: taxonomy.canChange, + }, }; // Remove hidden menu items @@ -94,6 +101,13 @@ const TaxonomyMenu = ({ taxonomyId={taxonomy.id} /> )} + {isManageOrgsModalOpen && ( + + )} ); diff --git a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx index 20e172ae62..24237b5761 100644 --- a/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx +++ b/src/taxonomy/taxonomy-menu/TaxonomyMenu.test.jsx @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import { TaxonomyContext } from '../common/context'; import initializeStore from '../../store'; -import { deleteTaxonomy, getTaxonomyExportFile } from '../data/api'; +import { deleteTaxonomy, getTaxonomy, getTaxonomyExportFile } from '../data/api'; import { importTaxonomyTags } from '../import-tags'; import { TaxonomyMenu } from '.'; @@ -24,6 +24,7 @@ jest.mock('../data/api', () => ({ ...jest.requireActual('../data/api'), getTaxonomyExportFile: jest.fn(), deleteTaxonomy: jest.fn(), + getTaxonomy: jest.fn(), })); const queryClient = new QueryClient(); @@ -115,14 +116,24 @@ describe.each([true, false])('', async (iconMenu) => }); test('Shows menu actions that user is permitted', () => { - const { getByTestId, queryByTestId } = render(); + const { getByTestId, queryByTestId } = render( + , + ); // Click on the menu button to open fireEvent.click(getByTestId('taxonomy-menu-button')); - // Ensure that the menu items are present + // Menu opened + expect(getByTestId('taxonomy-menu')).toBeVisible(); + + // Ensure expected menu items are found expect(queryByTestId('taxonomy-menu-export')).toBeInTheDocument(); expect(queryByTestId('taxonomy-menu-import')).toBeInTheDocument(); + expect(queryByTestId('taxonomy-menu-manageOrgs')).toBeInTheDocument(); expect(queryByTestId('taxonomy-menu-delete')).toBeInTheDocument(); }); @@ -141,6 +152,7 @@ describe.each([true, false])('', async (iconMenu) => // Ensure expected menu items are found expect(queryByTestId('taxonomy-menu-export')).toBeInTheDocument(); expect(queryByTestId('taxonomy-menu-import')).not.toBeInTheDocument(); + expect(queryByTestId('taxonomy-menu-manageOrgs')).not.toBeInTheDocument(); expect(queryByTestId('taxonomy-menu-delete')).not.toBeInTheDocument(); }); @@ -244,4 +256,34 @@ describe.each([true, false])('', async (iconMenu) => // Toast message shown expect(mockSetToastMessage).toBeCalledWith(`"${taxonomyName}" deleted`); }); + + it('should open manage orgs dialog menu click', async () => { + const { + findByText, getByTestId, getByText, queryByText, + } = render(); + + // We need to provide a taxonomy or the modal will not open + getTaxonomy.mockResolvedValue({ + id: 1, + name: 'Taxonomy 1', + orgs: [], + allOrgs: true, + }); + + // Modal closed + expect(queryByText('Assign to organizations')).not.toBeInTheDocument(); + + // Click on delete menu + fireEvent.click(getByTestId('taxonomy-menu-button')); + fireEvent.click(getByTestId('taxonomy-menu-manageOrgs')); + + // Modal opened + expect(await findByText('Assign to organizations')).toBeInTheDocument(); + + // Click on cancel button + fireEvent.click(getByText('Cancel')); + + // Modal closed + expect(queryByText('Assign to organizations')).not.toBeInTheDocument(); + }); }); diff --git a/src/taxonomy/taxonomy-menu/messages.js b/src/taxonomy/taxonomy-menu/messages.js index 7d2b105331..3a71118ccb 100644 --- a/src/taxonomy/taxonomy-menu/messages.js +++ b/src/taxonomy/taxonomy-menu/messages.js @@ -14,6 +14,10 @@ const messages = defineMessages({ id: 'course-authoring.taxonomy-menu.import.label', defaultMessage: 'Re-import', }, + manageOrgsMenu: { + id: 'course-authoring.taxonomy-menu.assign-orgs.label', + defaultMessage: 'Manage Organizations', + }, exportMenu: { id: 'course-authoring.taxonomy-menu.export.label', defaultMessage: 'Export',